diff --git a/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx b/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx index 0a9f9c47f48..1dbfc3eff3d 100644 --- a/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx @@ -1,15 +1,29 @@ 'use client' -import { type ReactNode, useEffect, useId, useState } from 'react' +import { type ReactNode, useEffect, useId, useRef, useState } from 'react' +import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { Chip, ChipDropdown, ChipInput, ChipTextarea, Label } from '@sim/emcn' +import { toError } from '@sim/utils/errors' import { DEMO_REQUEST_COMPANY_SIZE_OPTIONS, type DemoRequestBody, } from '@/lib/api/contracts/demo-requests' +import { getEnv } from '@/lib/core/config/env' import { isFreeEmailDomain } from '@/lib/messaging/email/free-email' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { useSubmitDemoRequest } from '@/hooks/queries/demo-requests' +/** Build-time-inlined Turnstile site key; absent when captcha isn't configured. */ +const TURNSTILE_SITE_KEY = getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY') + +/** + * How long Continue waits on the invisible challenge before sending the request + * without a token. An interactive challenge cannot be shown in the hidden + * widget, so waiting longer only delays the calendar; the server then marks the + * request unverified and the booking goes to manual review. + */ +const CAPTCHA_WAIT_MS = 5_000 + /** Options for the "What can we help you with?" select; the first is the default. */ const TOPIC_OPTIONS = [ { value: 'demo', label: 'Book a demo' }, @@ -155,17 +169,27 @@ function DemoField({ label, htmlFor, required, error, children }: DemoFieldProps * looks like an address attempt, and the primary {@link Chip} stays disabled until * every required field is valid. * - * On a valid submit it fires the inbound-demo notification to sales (via - * {@link useSubmitDemoRequest}, best-effort - never blocking) and composes a - * {@link DemoLead} (name, email, and a notes summary) handed to `onComplete`, - * which advances the card to the scheduler. + * On a valid submit it sends the demo request (via {@link useSubmitDemoRequest}) + * with an invisible Turnstile token and a honeypot `website` field, and only + * once the server has recorded it hands a {@link DemoLead} (name, email, and a + * notes summary) to `onComplete`, which advances the card to the scheduler. The + * record decides whether the booking is confirmed automatically or reviewed by + * hand, so the calendar never opens before it exists. A failed request keeps the + * form open with an inline error. */ export function DemoForm({ onComplete }: DemoFormProps) { const submitDemoRequest = useSubmitDemoRequest() const [form, setForm] = useState(INITIAL_STATE) + const [website, setWebsite] = useState('') + /** Turnstile mounts on first focus, like the scheduler preload, so its script never competes with page load. */ + const [captchaArmed, setCaptchaArmed] = useState(false) + const turnstileRef = useRef(null) + const widgetLoadedRef = useRef(false) - const setField = (key: keyof DemoFormState) => (value: string) => + const setField = (key: keyof DemoFormState) => (value: string) => { setForm((prev) => ({ ...prev, [key]: value })) + if (submitDemoRequest.isError) submitDemoRequest.reset() + } /** * Prefill the email from the `?email=` param the landing hero's CTA forwards @@ -204,24 +228,53 @@ export function DemoForm({ onComplete }: DemoFormProps) { ? 'Please use your work email address.' : undefined - const handleSubmit = () => { - if (!canSubmit) return + /** + * Runs the invisible challenge, falling back to no token when the widget never + * loaded or does not answer; the server then treats the request as unverified + * rather than rejecting it. + */ + const solveCaptcha = async (): Promise => { + const widget = turnstileRef.current + if (!TURNSTILE_SITE_KEY || !widgetLoadedRef.current || !widget) return undefined + try { + widget.reset() + widget.execute() + return await widget.getResponsePromise(CAPTCHA_WAIT_MS) + } catch { + return undefined + } + } + + const [isSolvingCaptcha, setIsSolvingCaptcha] = useState(false) + const isSubmitting = isSolvingCaptcha || submitDemoRequest.isPending + const submitError = submitDemoRequest.isError + ? toError(submitDemoRequest.error).message || 'Something went wrong. Please try again.' + : null - // Best-effort sales notification — fire-and-forget so it never blocks scheduling. - submitDemoRequest.mutate({ + const handleSubmit = async () => { + if (!canSubmit || isSubmitting) return + + const request: DemoRequestBody = { firstName: form.firstName.trim(), lastName: form.lastName.trim(), companyEmail: trimmedEmail, phoneNumber: form.phone.trim() || undefined, companySize: form.companySize as DemoRequestBody['companySize'], details: buildDetails(form), - }) + website, + } - onComplete({ + const lead: DemoLead = { name: `${form.firstName} ${form.lastName}`.trim(), email: trimmedEmail, notes: buildNotes(form), - }) + } + + setIsSolvingCaptcha(true) + const captchaToken = await solveCaptcha() + setIsSolvingCaptcha(false) + + submitDemoRequest.mutate({ ...request, captchaToken }, { onSuccess: () => onComplete(lead) }) } return ( @@ -236,114 +289,158 @@ export function DemoForm({ onComplete }: DemoFormProps) {
{ event.preventDefault() - handleSubmit() + void handleSubmit() }} aria-labelledby='demo-form-heading' - className='mt-5 flex flex-col gap-4' + className='relative mt-5 flex flex-col gap-4' noValidate + onFocusCapture={() => setCaptchaArmed(true)} > -
- + {/* Out of flow, so neither control adds a row or a gap to the pinned card height. */} + + {/* Locked while submitting, so the request and the scheduler's prefill never go stale mid-wait. */} +
+
+ + setField('firstName')(event.target.value)} + placeholder='Jane' + autoComplete='given-name' + /> + + + setField('lastName')(event.target.value)} + placeholder='Doe' + autoComplete='family-name' + /> + +
+ + setField('firstName')(event.target.value)} - placeholder='Jane' - autoComplete='given-name' + value={form.email} + onChange={(event) => setField('email')(event.target.value)} + error={Boolean(emailError)} + placeholder='jane@acme.co' + autoComplete='email' /> - + + setField('lastName')(event.target.value)} - placeholder='Doe' - autoComplete='family-name' + value={form.phone} + onChange={(event) => setField('phone')(event.target.value)} + placeholder='+1 (555) 000-0000' + autoComplete='tel' /> -
- - setField('email')(event.target.value)} - error={Boolean(emailError)} - placeholder='jane@acme.co' - autoComplete='email' - /> - - - - setField('phone')(event.target.value)} - placeholder='+1 (555) 000-0000' - autoComplete='tel' - /> - - - - setField('company')(event.target.value)} - placeholder='Acme Inc.' - autoComplete='organization' - /> - - - - - - - - - - - - setField('message')(event.target.value)} - placeholder='What are you hoping to build with Sim?' - rows={3} - /> - + + setField('company')(event.target.value)} + placeholder='Acme Inc.' + autoComplete='organization' + /> + + + + + + + + + + + + setField('message')(event.target.value)} + placeholder='What are you hoping to build with Sim?' + rows={3} + /> + + + {/* + `Chip` gives its label span `flex-1`; under `fullWidth` that left-aligns the + label, so center it with `justify-center` + a `flex-none` span override. + */} + + {submitError ? ( +

+ {submitError} +

+ ) : null} - {/* - `Chip` gives its label span `flex-1`; under `fullWidth` that left-aligns the - label, so center it with `justify-center` + a `flex-none` span override. - */} - Continue + {isSubmitting ? 'Continuing…' : 'Continue'}
diff --git a/apps/sim/app/api/demo-requests/bookings/route.ts b/apps/sim/app/api/demo-requests/bookings/route.ts new file mode 100644 index 00000000000..e016e4f8d9d --- /dev/null +++ b/apps/sim/app/api/demo-requests/bookings/route.ts @@ -0,0 +1,95 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { type NextRequest, NextResponse } from 'next/server' +import { + calBookingRequestSchema, + type calBookingWebhookContract, + calWebhookEnvelopeSchema, +} from '@/lib/api/contracts/demo-requests' +import type { ContractJsonResponse } from '@/lib/api/contracts/types' +import { env } from '@/lib/core/config/env' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { reviewDemoBookingRequest } from '@/lib/demo-booking/review' +import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' + +const logger = createLogger('DemoBookingWebhookAPI') + +const BODY_LABEL = 'Cal.com booking webhook body' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +type WebhookResponse = ContractJsonResponse + +const ACKNOWLEDGED: WebhookResponse = { ok: true } + +async function readBody(request: NextRequest): Promise { + assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, BODY_LABEL) + const buffer = await readStreamToBufferWithLimit(request.body, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + label: BODY_LABEL, + }) + return new TextDecoder().decode(buffer) +} + +/** + * Cal.com webhook for the `/demo` event type, which requires confirmation, so + * every booking arrives as a request that stays off calendars until decided. + * Subscribe it to `BOOKING_REQUESTED` on that event type only, signed with + * `CAL_WEBHOOK_SECRET`. The signature is an HMAC-SHA256 hex digest of the raw + * body in `x-cal-signature-256`. {@link reviewDemoBookingRequest} confirms, + * declines, or leaves each request for manual review. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const secret = env.CAL_WEBHOOK_SECRET + if (!secret) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + let rawBody: string + try { + rawBody = await readBody(request) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: 'Request body too large' }, { status: 413 }) + } + throw error + } + + const signature = request.headers.get('x-cal-signature-256') + if (!signature || !safeCompare(signature, hmacSha256Hex(rawBody, secret))) { + logger.warn('Rejected a demo booking webhook with an invalid signature') + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + + let json: unknown + try { + json = JSON.parse(rawBody) + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const envelope = calWebhookEnvelopeSchema.safeParse(json) + if (!envelope.success || envelope.data.triggerEvent !== 'BOOKING_REQUESTED') { + return NextResponse.json(ACKNOWLEDGED) + } + + const booking = calBookingRequestSchema.safeParse(envelope.data.payload) + if (!booking.success) { + logger.warn('Demo booking request is missing its uid or attendee') + return NextResponse.json(ACKNOWLEDGED) + } + + const decision = await reviewDemoBookingRequest({ + bookingUid: booking.data.uid, + attendeeEmail: booking.data.attendees[0].email, + }) + logger.info('Demo booking request reviewed', { decision }) + return NextResponse.json({ ok: true, decision }) +}) diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts index dd101b3b3e9..ca1dc395219 100644 --- a/apps/sim/app/api/demo-requests/route.ts +++ b/apps/sim/app/api/demo-requests/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' +import { after, type NextRequest, NextResponse } from 'next/server' import { formatAttributionForNotification } from '@/lib/analytics/attribution' import { getDemoRequestCompanySizeLabel, @@ -9,9 +9,11 @@ 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 { getClientIp } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type DemoBookerStatus, recordDemoBooker } from '@/lib/demo-booking/bookers' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' @@ -24,15 +26,37 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } +const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 3, + refillRate: 1, + refillIntervalMs: 60_000, +} + +const SUCCESS_RESPONSE = { success: true, message: 'Thanks! Our team will reach out shortly.' } +const TOO_MANY_REQUESTS_RESPONSE = { error: 'Too many requests. Please try again later.' } + +/** + * Public demo-form endpoint: per-IP rate limit, honeypot drop, captcha, then a + * record of how far the form vouched for the visitor's email, which decides + * their Cal.com booking (see {@link recordDemoBooker} and + * `lib/demo-booking/review.ts`). The form opens the calendar only after this + * answers, so a booking can never outrun its record; a failed write answers 503 + * and the visitor retries instead of booking unrecorded. The sales notification + * goes out after the response. + * + * The captcha mirrors `/api/contact`: a valid Turnstile token marks the email + * `verified`; a missing token or a Cloudflare transport error passes through the + * stricter no-captcha bucket and marks it `unverified`, so a person whose + * widget was blocked still books, pending a manual review; an invalid token is + * rejected. A tripped honeypot answers as a success and records nothing, so the + * bot's booking is declined. + */ export const POST = withRouteHandler(async (req: NextRequest) => { try { const ip = getClientIp(req) if (!ip) { logger.warn('Unable to resolve client IP for public rate limit') - return NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429 } - ) + return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 }) } const storageKey = `public:demo-request:${ip}` const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect( @@ -41,14 +65,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) if (!allowed) { - logger.warn(`Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) - return NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { - status: 429, - headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) }, - } - ) + logger.warn('Rate limit exceeded', { 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(submitDemoRequestContract, req, {}) @@ -57,8 +78,61 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return parsed.response } - const { firstName, lastName, companyEmail, phoneNumber, companySize, details } = - parsed.data.body + const { + firstName, + lastName, + companyEmail, + phoneNumber, + companySize, + details, + website, + captchaToken, + } = parsed.data.body + + if (website && website.trim().length > 0) { + logger.warn('Honeypot triggered, discarding', { ip }) + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) + } + + let bookerStatus: DemoBookerStatus = 'unverified' + if (isTurnstileConfigured()) { + const token = captchaToken && captchaToken.length > 0 ? captchaToken : null + if (token) { + const verification = await verifyTurnstileToken({ token, remoteIp: ip }) + if (verification.success) { + bookerStatus = 'verified' + } else if (!verification.transportError) { + logger.warn('Captcha verification failed', { ip, errorCodes: verification.errorCodes }) + return NextResponse.json( + { error: 'Captcha verification failed. Please try again.' }, + { status: 400 } + ) + } + } + + if (bookerStatus !== 'verified') { + const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect( + `public:demo-request:nocaptcha:${ip}`, + CAPTCHA_UNAVAILABLE_RATE_LIMIT, + { failClosed: true } + ) + if (!nocaptchaAllowed) { + logger.warn('Rate limit rejected (no-captcha)', { ip }) + return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 }) + } + } + } + + try { + await recordDemoBooker(companyEmail, bookerStatus) + } catch (error) { + logger.error('Could not record the demo booker', { error }) + return NextResponse.json( + { error: 'We could not save your request. Please try again.' }, + { status: 503 } + ) + } + const attribution = formatAttributionForNotification((name) => req.cookies.get(name)?.value) logger.info('Processing demo request', { @@ -77,26 +151,22 @@ Details: ${details} ${attribution ? `\nAttribution:\n${attribution}\n` : ''}` - const emailResult = await sendEmail({ - to: [`enterprise@${env.EMAIL_DOMAIN || getEmailDomain()}`], - subject: `[DEMO REQUEST] ${firstName} ${lastName}`, - text: emailText, - from: getFromEmailAddress(), - replyTo: companyEmail, - emailType: 'transactional', + // The visitor waits on this response before the calendar opens; the sales + // notification does not need to hold it up. + after(async () => { + const emailResult = await sendEmail({ + to: [`enterprise@${env.EMAIL_DOMAIN || getEmailDomain()}`], + subject: `[DEMO REQUEST] ${firstName} ${lastName}`, + text: emailText, + from: getFromEmailAddress(), + replyTo: companyEmail, + emailType: 'transactional', + }) + if (emailResult.success) logger.info('Demo request email sent successfully') + else logger.error('Error sending demo request email', emailResult.message) }) - if (!emailResult.success) { - logger.error('Error sending demo request email', emailResult.message) - return NextResponse.json({ error: 'Failed to submit request' }, { status: 500 }) - } - - logger.info('Demo request email sent successfully') - - return NextResponse.json( - { success: true, message: 'Thanks! Our team will reach out shortly.' }, - { status: 201 } - ) + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) } catch (error) { logger.error('Error processing demo request', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index 62c3a20e58f..ce84be57fdb 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { ArrowLeft, Chip, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' +import { SIM_SITE_URL } from '@sim/utils/site' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { PAGE_HEADER_BAR } from '@/components/page-header-bar' @@ -38,7 +39,8 @@ import { import { useFullscreenOriginStore } from '@/stores/fullscreen-origin/store' /** Enterprise "Talk to sales" books time with the sales team on Cal.com. */ -const SALES_CAL_URL = 'https://cal.com/team/sim/demo' as const +/** The demo form, so a sales booking passes the same spam checks as every other. */ +const SALES_DEMO_URL = `${SIM_SITE_URL}/demo` /** * Props for {@link Upgrade}. @@ -155,7 +157,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) { const onClick = (): void => { switch (cta.intent) { case 'sales': - window.open(SALES_CAL_URL, '_blank', 'noopener,noreferrer') + window.open(SALES_DEMO_URL, '_blank', 'noopener,noreferrer') return case 'downgrade': void state.onUpgradeToOtherTier() diff --git a/apps/sim/hooks/queries/demo-requests.ts b/apps/sim/hooks/queries/demo-requests.ts index 654aaf13e14..c28c7dad43c 100644 --- a/apps/sim/hooks/queries/demo-requests.ts +++ b/apps/sim/hooks/queries/demo-requests.ts @@ -11,10 +11,10 @@ import { captureClientEvent } from '@/lib/posthog/client' const logger = createLogger('DemoRequestMutation') /** - * Submit an inbound demo request. The route notifies the sales inbox - * (`enterprise@`, replying to the visitor) — no email is sent to the visitor. - * Used as a best-effort notification from the demo-booking flow: failures are - * logged and never block the visitor from scheduling. + * Submit an inbound demo request. The route records how far the form vouched + * for the visitor's email, which decides their booking, then notifies the sales + * inbox (`enterprise@`, replying to the visitor); no email goes to the visitor. + * The demo form opens the calendar only once this succeeds. */ export function useSubmitDemoRequest() { return useMutation({ diff --git a/apps/sim/lib/api/contracts/demo-requests.ts b/apps/sim/lib/api/contracts/demo-requests.ts index 47fb32a7088..34fc5e84196 100644 --- a/apps/sim/lib/api/contracts/demo-requests.ts +++ b/apps/sim/lib/api/contracts/demo-requests.ts @@ -55,6 +55,9 @@ export const demoRequestSchema = z.object({ error: 'Please select company size', }), details: z.string().trim().min(1, 'Details are required').max(2000), + /** Honeypot: hidden from people, so any value marks the submission as automated. */ + website: z.string().optional(), + captchaToken: z.string().optional(), }) export type DemoRequestPayload = z.infer @@ -80,3 +83,38 @@ export const submitDemoRequestContract = defineRouteContract({ schema: demoRequestResponseSchema, }, }) + +/** + * The envelope of a Cal.com webhook delivery. Only `BOOKING_REQUESTED` carries a + * booking to review; every other trigger, including the dashboard's test ping, + * is acknowledged and ignored. + */ +export const calWebhookEnvelopeSchema = z.object({ + triggerEvent: z.string(), + payload: z.unknown().optional(), +}) + +/** The fields of a requested booking that the demo booking review reads. */ +export const calBookingRequestSchema = z.object({ + uid: z.string().min(1), + attendees: z.array(z.object({ email: z.string().min(1) })).min(1), +}) + +/** + * The Cal.com webhook for the demo event. Cal.com is the only caller, so no + * client reads this contract; it declares the shapes the route accepts and + * answers. The route reads the raw body itself, because the signature covers + * the exact bytes Cal.com sent. + */ +export const calBookingWebhookContract = defineRouteContract({ + method: 'POST', + path: '/api/demo-requests/bookings', + body: calWebhookEnvelopeSchema, + response: { + mode: 'json', + schema: z.object({ + ok: z.literal(true), + decision: z.enum(['confirmed', 'declined', 'pending']).optional(), + }), + }, +}) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index b87c640130d..abf58c386b5 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -144,6 +144,8 @@ export const env = createEnv({ TRUSTED_ORIGINS: z.string().optional(), // Comma-separated additional origins to trust for auth (e.g., "https://app.example.com,https://www.example.com"). Merged into Better Auth trustedOrigins. BROWSER_EXTENSION_IDS: z.string().optional(), TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification + CAL_API_KEY: z.string().min(1).optional(), // Cal.com API key that confirms or declines /demo booking requests (unset leaves every request for manual review) + CAL_WEBHOOK_SECRET: z.string().min(1).optional(), // Secret Cal.com signs /demo booking webhooks with (unset disables the webhook) ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS) ENCRYPT_ACCOUNT_TOKENS: z.boolean().optional(), // Opt-out: OAuth access/refresh tokens are sealed on write (read by @sim/db) and backfilled by migrate whenever ENCRYPTION_KEY is 64 hex characters; false or 0 writes plaintext. Sealed tokens need v0.9.33+ on every process that reads them diff --git a/apps/sim/lib/demo-booking/bookers.ts b/apps/sim/lib/demo-booking/bookers.ts new file mode 100644 index 00000000000..835546ed704 --- /dev/null +++ b/apps/sim/lib/demo-booking/bookers.ts @@ -0,0 +1,80 @@ +import { createHash } from 'node:crypto' +import { getRedisClient } from '@/lib/core/config/redis' + +/** How long a submitted demo form vouches for a booking made with the same email. */ +const BOOKER_TTL_SECONDS = 24 * 60 * 60 + +/** How long a booking keeps the lookup it claimed, which covers Cal.com's repeat deliveries. */ +const CLAIM_TTL_SECONDS = 7 * 24 * 60 * 60 + +/** + * How far the demo form vouched for an email. `verified` passed Turnstile. + * `unverified` reached the server without a Turnstile pass the server could + * check (the widget was blocked or unsupported, or Cloudflare was unreachable), + * so a person may still be behind it. + */ +export type DemoBookerStatus = 'verified' | 'unverified' + +/** + * What a booking request finds for its attendee: the form's verdict, `unseen` + * when no form submission vouches for the email, or `unavailable` when there is + * no Redis to ask. + */ +export type DemoBookerLookup = DemoBookerStatus | 'unseen' | 'unavailable' + +/** Keys on a hash, so Redis never holds a visitor's email address. */ +function bookerKey(email: string): string { + const digest = createHash('sha256').update(email.trim().toLowerCase()).digest('hex') + return `demo-booking:booker:${digest}` +} + +/** + * Records that the demo form vouched for an email. A `verified` record replaces + * any earlier one; an `unverified` record never downgrades a `verified` one. + */ +export async function recordDemoBooker(email: string, status: DemoBookerStatus): Promise { + const redis = getRedisClient() + if (!redis) return + if (status === 'verified') { + await redis.set(bookerKey(email), status, 'EX', BOOKER_TTL_SECONDS) + } else { + await redis.set(bookerKey(email), status, 'EX', BOOKER_TTL_SECONDS, 'NX') + } +} + +/** + * Atomically: return the booking's claim if one exists; otherwise consume the + * email's record, store the result (`unseen` when there was none) as the + * booking's claim, and return it. As one script, two overlapping deliveries for + * the same booking can never split the record from the claim. + */ +const CLAIM_SCRIPT = ` +local claimed = redis.call('GET', KEYS[1]) +if claimed then return claimed end +local lookup = redis.call('GETDEL', KEYS[2]) or 'unseen' +redis.call('SET', KEYS[1], lookup, 'EX', ARGV[1]) +return lookup +` + +/** + * Claims the email's record for one booking and remembers the result against + * that booking. One form submission vouches for exactly one booking, and a + * repeated webhook delivery for the same booking reads back the same lookup + * instead of finding the record already spent. + */ +export async function claimDemoBooker( + email: string, + bookingUid: string +): Promise { + const redis = getRedisClient() + if (!redis) return 'unavailable' + + const lookup = await redis.eval( + CLAIM_SCRIPT, + 2, + `demo-booking:claim:${bookingUid}`, + bookerKey(email), + String(CLAIM_TTL_SECONDS) + ) + return lookup === 'verified' || lookup === 'unverified' ? lookup : 'unseen' +} diff --git a/apps/sim/lib/demo-booking/review.ts b/apps/sim/lib/demo-booking/review.ts new file mode 100644 index 00000000000..ef93a53f471 --- /dev/null +++ b/apps/sim/lib/demo-booking/review.ts @@ -0,0 +1,104 @@ +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' +import { SIM_SITE_URL } from '@sim/utils/site' +import { env } from '@/lib/core/config/env' +import { claimDemoBooker, type DemoBookerLookup } from '@/lib/demo-booking/bookers' + +const logger = createLogger('DemoBookingReview') + +const CAL_API_URL = 'https://api.cal.com/v2' +const CAL_API_VERSION = '2026-02-25' +const MAX_ATTEMPTS = 2 +const REQUEST_TIMEOUT_MS = 10_000 +const DECLINE_REASON = `Please book your demo through ${SIM_SITE_URL}/demo.` + +/** + * What happened to a requested booking. `pending` leaves it in Cal.com's + * unconfirmed list for the team to review by hand, so it never reaches a + * calendar unless someone approves it. + */ +type DemoBookingDecision = 'confirmed' | 'declined' | 'pending' + +interface DemoBookingRequest { + bookingUid: string + attendeeEmail: string +} + +/** + * Calls one Cal.com booking action. Network failures and 5xx responses are + * retried once; a 4xx is terminal. Returns whether Cal.com accepted the call. + */ +async function callBookingAction( + apiKey: string, + bookingUid: string, + action: 'confirm' | 'decline', + body?: Record +): Promise { + const url = `${CAL_API_URL}/bookings/${encodeURIComponent(bookingUid)}/${action}` + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + let status: number | undefined + try { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'cal-api-version': CAL_API_VERSION, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + status = response.status + if (response.ok) return true + if (status < 500) { + logger.warn('Cal.com rejected the booking action', { action, status }) + return false + } + } catch (error) { + logger.warn('Cal.com booking action failed', { action, attempt, error }) + } + if (attempt < MAX_ATTEMPTS) await sleep(backoffWithJitter(attempt, null)) + else logger.error('Cal.com booking action gave up', { action, status }) + } + return false +} + +/** + * Decides a requested demo booking from what the demo form recorded for its + * attendee's email: + * + * - `verified`: confirmed, so it lands on the host's calendar. + * - `unverified`: left pending for a person to review. + * - `unseen`: declined. No form submission vouches for the email, which is what + * a bot booking the public Cal.com link directly looks like. + * + * Without an API key, or when the record cannot be read, every request is left + * pending: failing closed here only costs the team a manual approval. + */ +export async function reviewDemoBookingRequest({ + bookingUid, + attendeeEmail, +}: DemoBookingRequest): Promise { + const apiKey = env.CAL_API_KEY + if (!apiKey) return 'pending' + + let booker: DemoBookerLookup + try { + booker = await claimDemoBooker(attendeeEmail, bookingUid) + } catch (error) { + logger.warn('Could not read the demo booker record', { error }) + return 'pending' + } + + if (booker === 'verified') { + return (await callBookingAction(apiKey, bookingUid, 'confirm')) ? 'confirmed' : 'pending' + } + if (booker === 'unseen') { + const declined = await callBookingAction(apiKey, bookingUid, 'decline', { + reason: DECLINE_REASON, + }) + return declined ? 'declined' : 'pending' + } + return 'pending' +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index b72ac6efc06..2bb8b96f9f1 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -491,7 +491,7 @@ const nextConfig: NextConfig = { }, { source: '/team', - destination: 'https://cal.com/team/sim/demo', + destination: `${SIM_SITE_URL}/demo`, permanent: false, }, {