rogasper commited on
Commit
83e82db
·
1 Parent(s): f68b7db

feat: integrate Cloudflare Turnstile for enhanced security in sign-up and email verification processes. Implement rate limiting for sign-up attempts and add Turnstile validation to prevent abuse. Update environment configuration to support Turnstile keys and enhance user experience with improved error handling and feedback.

Browse files
.gitignore CHANGED
@@ -9,6 +9,7 @@ references
9
  # Build outputs
10
  dist
11
  build
 
12
  *.tsbuildinfo
13
 
14
  # Environment variables
 
9
  # Build outputs
10
  dist
11
  build
12
+ blog
13
  *.tsbuildinfo
14
 
15
  # Environment variables
apps/server/.env.example CHANGED
@@ -31,3 +31,5 @@ SMTP_FROM=
31
  # PLATFORM_AI_API_KEY=
32
  # PLATFORM_AI_BASE_URL=
33
  # PLATFORM_AI_MODEL=
 
 
 
31
  # PLATFORM_AI_API_KEY=
32
  # PLATFORM_AI_BASE_URL=
33
  # PLATFORM_AI_MODEL=
34
+
35
+ CLOUDFLARE_TURNSTILE_SECRET_KEY=
apps/server/.gitignore CHANGED
@@ -53,3 +53,5 @@ lerna-debug.log*
53
  # typescript
54
  *.tsbuildinfo
55
  next-env.d.ts
 
 
 
53
  # typescript
54
  *.tsbuildinfo
55
  next-env.d.ts
56
+
57
+ audio-cache/
apps/server/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { trpcServer } from "@hono/trpc-server";
2
  import { createContext } from "@labas/api/context";
3
  import { appRouter } from "@labas/api/routers/index";
 
4
  import { auth } from "@labas/auth";
5
  import { env } from "@labas/env/server";
6
  import { withRequestId } from "@labas/api/logger";
@@ -44,6 +45,21 @@ app.use(
44
  }),
45
  );
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
48
 
49
  app.use(
 
1
  import { trpcServer } from "@hono/trpc-server";
2
  import { createContext } from "@labas/api/context";
3
  import { appRouter } from "@labas/api/routers/index";
4
+ import { checkRateLimitAllowed } from "@labas/api/lib/rate-limit";
5
  import { auth } from "@labas/auth";
6
  import { env } from "@labas/env/server";
7
  import { withRequestId } from "@labas/api/logger";
 
45
  }),
46
  );
47
 
48
+ // Rate limit sign-up attempts per IP to mitigate mass account creation
49
+ app.use("/api/auth/sign-up/email", async (c, next) => {
50
+ if (c.req.method !== "POST") return next();
51
+ const cfIp = c.req.header("cf-connecting-ip");
52
+ const forwarded = c.req.header("x-forwarded-for");
53
+ const realIp = c.req.header("x-real-ip");
54
+ const firstForwardedIp = forwarded ? forwarded.split(",")[0]?.trim() : undefined;
55
+ const ip = cfIp ?? firstForwardedIp ?? realIp ?? "unknown";
56
+ const allowed = await checkRateLimitAllowed({ key: `signup:ip:${ip}`, limit: 5, windowMs: 3_600_000 });
57
+ if (!allowed) {
58
+ return c.json({ error: "Too many sign-up attempts. Please try again later." }, 429);
59
+ }
60
+ return next();
61
+ });
62
+
63
  app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
64
 
65
  app.use(
apps/web/.env.example CHANGED
@@ -1,3 +1,3 @@
1
  VITE_SERVER_URL=http://localhost:3000
2
  VITE_SITE_URL=http://localhost:5173
3
-
 
1
  VITE_SERVER_URL=http://localhost:3000
2
  VITE_SITE_URL=http://localhost:5173
3
+ VITE_CLOUDFLARE_TURNSTILE_SITE_KEY=
apps/web/package.json CHANGED
@@ -22,6 +22,7 @@
22
  "@labas/auth": "workspace:*",
23
  "@labas/env": "workspace:*",
24
  "@labas/ui": "workspace:*",
 
25
  "@tailwindcss/vite": "^4.2.2",
26
  "@tanstack/react-form": "^1.28.0",
27
  "@tanstack/react-query": "^5.90.12",
 
22
  "@labas/auth": "workspace:*",
23
  "@labas/env": "workspace:*",
24
  "@labas/ui": "workspace:*",
25
+ "@marsidev/react-turnstile": "^1.5.2",
26
  "@tailwindcss/vite": "^4.2.2",
27
  "@tanstack/react-form": "^1.28.0",
28
  "@tanstack/react-query": "^5.90.12",
apps/web/src/components/TurnstileField.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Turnstile, type TurnstileInstance, type TurnstileProps } from "@marsidev/react-turnstile";
2
+ import { forwardRef } from "react";
3
+ import { env } from "@labas/env/web";
4
+
5
+ export const TURNSTILE_SITE_KEY = env.VITE_CLOUDFLARE_TURNSTILE_SITE_KEY;
6
+
7
+ type TurnstileFieldProps = Omit<TurnstileProps, "siteKey"> & {
8
+ siteKey?: string;
9
+ };
10
+
11
+ export const TurnstileField = forwardRef<TurnstileInstance, TurnstileFieldProps>(
12
+ function TurnstileField({ options, className, siteKey, ...props }, ref) {
13
+ const resolvedSiteKey = siteKey ?? TURNSTILE_SITE_KEY;
14
+ if (!resolvedSiteKey) return null;
15
+
16
+ return (
17
+ <div className="w-full">
18
+ <div className="flex w-full items-center justify-center overflow-hidden rounded-[var(--radius-lg)] border border-[var(--oat-border)] bg-muted/40 px-2 py-2.5">
19
+ <Turnstile
20
+ ref={ref}
21
+ siteKey={resolvedSiteKey}
22
+ className={className ?? "w-full min-h-[65px] [&>iframe]:mx-auto"}
23
+ options={{ theme: "auto", size: "flexible", ...options }}
24
+ {...props}
25
+ />
26
+ </div>
27
+ </div>
28
+ );
29
+ },
30
+ );
apps/web/src/components/sign-up-form.tsx CHANGED
@@ -7,11 +7,14 @@ import { useMutation } from "@tanstack/react-query";
7
  import { useNavigate } from "@tanstack/react-router";
8
  import { toast } from "sonner";
9
  import z from "zod";
 
 
10
 
11
  import { authClient } from "@/lib/auth-client";
12
  import { trpc } from "@/utils/trpc";
13
  import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
14
  import { PasswordInput } from "@/components/ui/PasswordInput";
 
15
 
16
  import Loader from "./loader";
17
 
@@ -40,6 +43,8 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
40
  });
41
  const { isPending } = authClient.useSession();
42
  const sendOtpMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
 
 
43
 
44
  const form = useForm({
45
  defaultValues: {
@@ -59,8 +64,10 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
59
  onSuccess: async () => {
60
  trackUmamiEvent(AnalyticsEvent.SIGN_UP);
61
  try {
62
- await sendOtpMutation.mutateAsync({ email: value.email });
63
  } catch {}
 
 
64
  navigate({
65
  to: "/verify-email",
66
  search: { email: value.email },
@@ -68,6 +75,8 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
68
  toast.success("Akun berhasil dibuat! Cek email Anda untuk kode verifikasi.");
69
  },
70
  onError: (error) => {
 
 
71
  toast.error(error.error.message || error.error.statusText);
72
  },
73
  },
@@ -190,11 +199,22 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
190
  </form.Field>
191
  </div>
192
 
 
 
 
 
 
 
 
193
  <form.Subscribe
194
  selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
195
  >
196
  {({ canSubmit, isSubmitting }) => (
197
- <Button type="submit" className="w-full rounded-[var(--radius-lg)]" disabled={!canSubmit || isSubmitting}>
 
 
 
 
198
  {isSubmitting ? "Submitting..." : "Sign Up"}
199
  </Button>
200
  )}
 
7
  import { useNavigate } from "@tanstack/react-router";
8
  import { toast } from "sonner";
9
  import z from "zod";
10
+ import type { TurnstileInstance } from "@marsidev/react-turnstile";
11
+ import { useRef, useState } from "react";
12
 
13
  import { authClient } from "@/lib/auth-client";
14
  import { trpc } from "@/utils/trpc";
15
  import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
16
  import { PasswordInput } from "@/components/ui/PasswordInput";
17
+ import { TurnstileField, TURNSTILE_SITE_KEY } from "@/components/TurnstileField";
18
 
19
  import Loader from "./loader";
20
 
 
43
  });
44
  const { isPending } = authClient.useSession();
45
  const sendOtpMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
46
+ const [turnstileToken, setTurnstileToken] = useState<string | undefined>(undefined);
47
+ const turnstileRef = useRef<TurnstileInstance>(null);
48
 
49
  const form = useForm({
50
  defaultValues: {
 
64
  onSuccess: async () => {
65
  trackUmamiEvent(AnalyticsEvent.SIGN_UP);
66
  try {
67
+ await sendOtpMutation.mutateAsync({ email: value.email, turnstileToken });
68
  } catch {}
69
+ turnstileRef.current?.reset();
70
+ setTurnstileToken(undefined);
71
  navigate({
72
  to: "/verify-email",
73
  search: { email: value.email },
 
75
  toast.success("Akun berhasil dibuat! Cek email Anda untuk kode verifikasi.");
76
  },
77
  onError: (error) => {
78
+ turnstileRef.current?.reset();
79
+ setTurnstileToken(undefined);
80
  toast.error(error.error.message || error.error.statusText);
81
  },
82
  },
 
199
  </form.Field>
200
  </div>
201
 
202
+ <TurnstileField
203
+ ref={turnstileRef}
204
+ onSuccess={setTurnstileToken}
205
+ onExpire={() => setTurnstileToken(undefined)}
206
+ onError={() => setTurnstileToken(undefined)}
207
+ />
208
+
209
  <form.Subscribe
210
  selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
211
  >
212
  {({ canSubmit, isSubmitting }) => (
213
+ <Button
214
+ type="submit"
215
+ className="w-full rounded-[var(--radius-lg)]"
216
+ disabled={!canSubmit || isSubmitting || (!!TURNSTILE_SITE_KEY && !turnstileToken)}
217
+ >
218
  {isSubmitting ? "Submitting..." : "Sign Up"}
219
  </Button>
220
  )}
apps/web/src/routes/verify-email.tsx CHANGED
@@ -5,10 +5,12 @@ import { Button } from "@labas/ui/components/button";
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
6
  import { Input } from "@labas/ui/components/input";
7
  import { toast } from "sonner";
 
8
  import { getErrorMessage } from "@/lib/error-utils";
9
  import { authClient } from "@/lib/auth-client";
10
  import { trpc } from "@/utils/trpc";
11
  import { routeShell } from "@/lib/route-shell";
 
12
  import { z } from "zod";
13
 
14
  const searchSchema = z.object({
@@ -40,6 +42,8 @@ function RouteComponent() {
40
 
41
  const sendMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
42
  const verifyMutation = useMutation(trpc.verification.verifyEmailOtp.mutationOptions());
 
 
43
 
44
  useEffect(() => {
45
  if (!searchEmail) {
@@ -66,13 +70,17 @@ function RouteComponent() {
66
  inputRefs.current[0]?.focus();
67
  }, []);
68
 
69
- const handleSendOtp = async () => {
70
  if (!email) return;
71
  try {
72
- await sendMutation.mutateAsync({ email });
73
  toast.success("Kode OTP telah dikirim ke email Anda");
74
  setCountdown(60);
 
 
75
  } catch (err: unknown) {
 
 
76
  toast.error(getErrorMessage(err) || "Gagal mengirim OTP");
77
  }
78
  };
@@ -173,16 +181,42 @@ function RouteComponent() {
173
  {verifyMutation.isPending ? "Memverifikasi..." : "Verifikasi Email"}
174
  </Button>
175
 
176
- <div className="text-center text-sm text-muted-foreground">
177
- Tidak menerima kode?{" "}
178
- <Button
179
- variant="link"
180
- className="p-0 h-auto font-semibold"
181
- disabled={sendMutation.isPending || countdown > 0}
182
- onClick={handleSendOtp}
183
- >
184
- {countdown > 0 ? `Kirim ulang (${countdown}s)` : "Kirim Ulang"}
185
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  </div>
187
 
188
  <div className="text-center">
 
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
6
  import { Input } from "@labas/ui/components/input";
7
  import { toast } from "sonner";
8
+ import type { TurnstileInstance } from "@marsidev/react-turnstile";
9
  import { getErrorMessage } from "@/lib/error-utils";
10
  import { authClient } from "@/lib/auth-client";
11
  import { trpc } from "@/utils/trpc";
12
  import { routeShell } from "@/lib/route-shell";
13
+ import { TurnstileField, TURNSTILE_SITE_KEY } from "@/components/TurnstileField";
14
  import { z } from "zod";
15
 
16
  const searchSchema = z.object({
 
42
 
43
  const sendMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
44
  const verifyMutation = useMutation(trpc.verification.verifyEmailOtp.mutationOptions());
45
+ const [resendTurnstileToken, setResendTurnstileToken] = useState<string | undefined>(undefined);
46
+ const resendTurnstileRef = useRef<TurnstileInstance>(null);
47
 
48
  useEffect(() => {
49
  if (!searchEmail) {
 
70
  inputRefs.current[0]?.focus();
71
  }, []);
72
 
73
+ const handleSendOtp = async (token?: string) => {
74
  if (!email) return;
75
  try {
76
+ await sendMutation.mutateAsync({ email, turnstileToken: token });
77
  toast.success("Kode OTP telah dikirim ke email Anda");
78
  setCountdown(60);
79
+ resendTurnstileRef.current?.reset();
80
+ setResendTurnstileToken(undefined);
81
  } catch (err: unknown) {
82
+ resendTurnstileRef.current?.reset();
83
+ setResendTurnstileToken(undefined);
84
  toast.error(getErrorMessage(err) || "Gagal mengirim OTP");
85
  }
86
  };
 
181
  {verifyMutation.isPending ? "Memverifikasi..." : "Verifikasi Email"}
182
  </Button>
183
 
184
+ <div className="space-y-3">
185
+ {countdown === 0 && TURNSTILE_SITE_KEY ? (
186
+ <div className="space-y-2">
187
+ <p className="text-center text-xs text-muted-foreground">
188
+ Verifikasi keamanan untuk kirim ulang kode
189
+ </p>
190
+ <TurnstileField
191
+ ref={resendTurnstileRef}
192
+ onSuccess={(token) => {
193
+ setResendTurnstileToken(token);
194
+ handleSendOtp(token);
195
+ }}
196
+ onExpire={() => setResendTurnstileToken(undefined)}
197
+ onError={() => setResendTurnstileToken(undefined)}
198
+ />
199
+ </div>
200
+ ) : null}
201
+ <p className="text-center text-sm text-muted-foreground">
202
+ Tidak menerima kode?{" "}
203
+ {countdown > 0 ? (
204
+ <span className="font-semibold text-foreground">Kirim ulang ({countdown}s)</span>
205
+ ) : TURNSTILE_SITE_KEY ? (
206
+ <span className="font-semibold text-foreground">
207
+ {sendMutation.isPending ? "Mengirim..." : "Selesaikan verifikasi di atas"}
208
+ </span>
209
+ ) : (
210
+ <Button
211
+ variant="link"
212
+ className="p-0 h-auto font-semibold"
213
+ disabled={sendMutation.isPending}
214
+ onClick={() => handleSendOtp()}
215
+ >
216
+ {sendMutation.isPending ? "Mengirim..." : "Kirim Ulang"}
217
+ </Button>
218
+ )}
219
+ </p>
220
  </div>
221
 
222
  <div className="text-center">
bun.lock CHANGED
@@ -50,6 +50,7 @@
50
  "@labas/auth": "workspace:*",
51
  "@labas/env": "workspace:*",
52
  "@labas/ui": "workspace:*",
 
53
  "@tailwindcss/vite": "^4.2.2",
54
  "@tanstack/react-form": "^1.28.0",
55
  "@tanstack/react-query": "^5.90.12",
@@ -630,6 +631,8 @@
630
 
631
  "@labas/ui": ["@labas/ui@workspace:packages/ui"],
632
 
 
 
633
  "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
634
 
635
  "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.9", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA=="],
 
50
  "@labas/auth": "workspace:*",
51
  "@labas/env": "workspace:*",
52
  "@labas/ui": "workspace:*",
53
+ "@marsidev/react-turnstile": "^1.5.2",
54
  "@tailwindcss/vite": "^4.2.2",
55
  "@tanstack/react-form": "^1.28.0",
56
  "@tanstack/react-query": "^5.90.12",
 
631
 
632
  "@labas/ui": ["@labas/ui@workspace:packages/ui"],
633
 
634
+ "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.5.2", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-+3aBPxp86JzSC0ZmgyonoGoUEENcUkH3LGahXSpkV87ArvD2DzRCmPgh0FyQk6PQRmJwQJDAfwNavFsxUxMQWA=="],
635
+
636
  "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
637
 
638
  "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.9", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA=="],
packages/api/src/__tests__/test-db.ts CHANGED
@@ -19,7 +19,7 @@ export async function createTestDb() {
19
  const tables = [
20
  `CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
21
  `CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
22
- `CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL)`,
23
  `CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
24
  `CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
25
  `CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
 
19
  const tables = [
20
  `CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
21
  `CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
22
+ `CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
23
  `CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
24
  `CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
25
  `CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
packages/api/src/__tests__/test-setup.ts CHANGED
@@ -23,7 +23,7 @@ async function initSchema(pg: PGlite) {
23
  const tables = [
24
  `CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, role text DEFAULT 'user' NOT NULL, suspended boolean DEFAULT false NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
25
  `CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
26
- `CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL)`,
27
  `CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
28
  `CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
29
  `CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
@@ -53,10 +53,12 @@ export async function createTestUserData() {
53
  const [user1] = await db.insert(schema.user).values({
54
  id: "user-1", name: "Test User", email: "test@test.com", role: "admin",
55
  }).returning();
 
56
 
57
  const [user2] = await db.insert(schema.user).values({
58
  id: "user-2", name: "Other User", email: "other@test.com",
59
  }).returning();
 
60
 
61
  await db.insert(schema.examType).values([
62
  { id: "IELTS", name: "IELTS", language: "English" },
 
23
  const tables = [
24
  `CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, role text DEFAULT 'user' NOT NULL, suspended boolean DEFAULT false NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
25
  `CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
26
+ `CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
27
  `CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
28
  `CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
29
  `CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
 
53
  const [user1] = await db.insert(schema.user).values({
54
  id: "user-1", name: "Test User", email: "test@test.com", role: "admin",
55
  }).returning();
56
+ if (!user1) throw new Error("Failed to seed test user 1");
57
 
58
  const [user2] = await db.insert(schema.user).values({
59
  id: "user-2", name: "Other User", email: "other@test.com",
60
  }).returning();
61
+ if (!user2) throw new Error("Failed to seed test user 2");
62
 
63
  await db.insert(schema.examType).values([
64
  { id: "IELTS", name: "IELTS", language: "English" },
packages/api/src/__tests__/verification.integration.test.ts ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeAll, afterAll, mock } from "bun:test";
2
+ import { drizzle } from "drizzle-orm/pglite";
3
+ import { eq } from "drizzle-orm";
4
+ import * as schema from "../../../db/src/schema";
5
+ import { getTestPGlite, closeTestPGlite } from "./test-setup";
6
+
7
+ // ── Mocks (must be declared before dynamic imports) ──────────────────────────
8
+
9
+ mock.module("@labas/env/server", () => ({
10
+ env: {
11
+ DATABASE_URL: "postgres://localhost:5432/test",
12
+ BETTER_AUTH_SECRET: "a".repeat(32),
13
+ BETTER_AUTH_URL: "http://localhost:3000",
14
+ CORS_ORIGIN: "http://localhost:5173",
15
+ API_KEY_ENCRYPTION_KEY: "z".repeat(32),
16
+ REDIS_URL: "redis://localhost:6379",
17
+ SMTP_HOST: "localhost",
18
+ SMTP_USER: "test",
19
+ SMTP_PASS: "test",
20
+ SMTP_FROM: "test@test.com",
21
+ FREE_CREDITS_ENABLED: false,
22
+ DEFAULT_SIGNUP_CREDIT_TOKENS: 50_000,
23
+ // CLOUDFLARE_TURNSTILE_SECRET_KEY intentionally omitted — Turnstile disabled
24
+ },
25
+ }));
26
+
27
+ const mockCheckRateLimit = mock(async () => {});
28
+ mock.module("../lib/rate-limit", () => ({
29
+ checkRateLimit: mockCheckRateLimit,
30
+ checkRateLimitAllowed: mock(async () => true),
31
+ }));
32
+
33
+ const mockSendOtpEmail = mock(async (_opts: unknown) => {});
34
+ mock.module("../lib/email", () => ({
35
+ sendOtpEmail: mockSendOtpEmail,
36
+ }));
37
+
38
+ mock.module("../lib/turnstile", () => ({
39
+ validateTurnstileToken: mock(async () => {}),
40
+ }));
41
+
42
+ // ── Tests ─────────────────────────────────────────────────────────────────────
43
+
44
+ const TEST_IP = "203.0.113.42";
45
+ const EMAIL_UNVERIFIED = "unverified@example.com";
46
+ const EMAIL_VERIFIED = "verified@example.com";
47
+ const EMAIL_NONEXISTENT = "ghost@example.com";
48
+
49
+ describe("verification router", () => {
50
+ let db: ReturnType<typeof drizzle>;
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ let verificationRouter: any;
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ let caller: any;
55
+
56
+ beforeAll(async () => {
57
+ const pg = await getTestPGlite();
58
+ db = drizzle(pg, { schema });
59
+
60
+ mock.module("@labas/db", () => ({
61
+ __esModule: true,
62
+ ...schema,
63
+ db,
64
+ }));
65
+
66
+ const mod = await import("../routers/verification");
67
+ verificationRouter = mod.verificationRouter;
68
+ caller = verificationRouter.createCaller({ session: null, auth: null, ip: TEST_IP });
69
+
70
+ await db.insert(schema.user).values([
71
+ { id: "v-user-1", name: "Unverified User", email: EMAIL_UNVERIFIED, emailVerified: false },
72
+ { id: "v-user-2", name: "Verified User", email: EMAIL_VERIFIED, emailVerified: true },
73
+ ]);
74
+
75
+ // Seed account row so resetPassword can update the password hash
76
+ await db.insert(schema.account).values({
77
+ id: "v-account-1",
78
+ accountId: "v-user-1",
79
+ providerId: "credential",
80
+ userId: "v-user-1",
81
+ password: "hashed_old_password",
82
+ });
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await closeTestPGlite();
87
+ });
88
+
89
+ // ── sendVerificationOtp ───────────────────────────────────────────────────
90
+
91
+ describe("sendVerificationOtp", () => {
92
+ it("sends email and returns generic success for unverified user", async () => {
93
+ mockSendOtpEmail.mockClear();
94
+ const result = await caller.sendVerificationOtp({ email: EMAIL_UNVERIFIED });
95
+ expect(result.success).toBe(true);
96
+ expect(mockSendOtpEmail).toHaveBeenCalledTimes(1);
97
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
+ const callArg = (mockSendOtpEmail.mock.calls[0] as any[])[0] as { to: string; type: string; otp: string };
99
+ expect(callArg.to).toBe(EMAIL_UNVERIFIED);
100
+ expect(callArg.type).toBe("email-verification");
101
+ expect(callArg.otp).toMatch(/^\d{6}$/);
102
+ });
103
+
104
+ it("silently returns generic response for non-existent email (no enumeration)", async () => {
105
+ mockSendOtpEmail.mockClear();
106
+ const result = await caller.sendVerificationOtp({ email: EMAIL_NONEXISTENT });
107
+ expect(result.success).toBe(true);
108
+ expect(mockSendOtpEmail).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("silently returns generic response for already-verified email", async () => {
112
+ mockSendOtpEmail.mockClear();
113
+ const result = await caller.sendVerificationOtp({ email: EMAIL_VERIFIED });
114
+ expect(result.success).toBe(true);
115
+ expect(mockSendOtpEmail).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it("checks per-email and per-IP rate limits with correct keys", async () => {
119
+ mockCheckRateLimit.mockClear();
120
+ await caller.sendVerificationOtp({ email: EMAIL_UNVERIFIED });
121
+ const keys = mockCheckRateLimit.mock.calls.map((c: unknown[]) => (c[0] as { key: string }).key);
122
+ expect(keys).toContain(`otp-send:email:${EMAIL_UNVERIFIED}`);
123
+ expect(keys).toContain(`otp-send:ip:${TEST_IP}:email`);
124
+ });
125
+
126
+ it("passes strict:true to checkRateLimit", async () => {
127
+ mockCheckRateLimit.mockClear();
128
+ await caller.sendVerificationOtp({ email: EMAIL_UNVERIFIED });
129
+ const allStrict = mockCheckRateLimit.mock.calls.every(
130
+ (c: unknown[]) => (c[0] as { strict?: boolean }).strict === true,
131
+ );
132
+ expect(allStrict).toBe(true);
133
+ });
134
+ });
135
+
136
+ // ── verifyEmailOtp ────────────────────────────────────────────────────────
137
+
138
+ describe("verifyEmailOtp", () => {
139
+ it("verifies email and returns success with correct OTP", async () => {
140
+ // Seed a fresh OTP directly into the verification table
141
+ const otp = "123456";
142
+ await db.delete(schema.verification).where(
143
+ eq(schema.verification.identifier, `email-verification:${EMAIL_UNVERIFIED}`),
144
+ );
145
+ await db.insert(schema.verification).values({
146
+ id: "v-otp-1",
147
+ identifier: `email-verification:${EMAIL_UNVERIFIED}`,
148
+ value: otp,
149
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000),
150
+ });
151
+
152
+ const result = await caller.verifyEmailOtp({ email: EMAIL_UNVERIFIED, otp });
153
+ expect(result.success).toBe(true);
154
+
155
+ // emailVerified should now be true in the DB
156
+ const [updated] = await db
157
+ .select({ emailVerified: schema.user.emailVerified })
158
+ .from(schema.user)
159
+ .where(eq(schema.user.email, EMAIL_UNVERIFIED));
160
+ expect(updated?.emailVerified).toBe(true);
161
+
162
+ // OTP record should be deleted after use
163
+ const remaining = await db
164
+ .select()
165
+ .from(schema.verification)
166
+ .where(eq(schema.verification.id, "v-otp-1"));
167
+ expect(remaining).toHaveLength(0);
168
+ });
169
+
170
+ it("throws BAD_REQUEST for wrong OTP", async () => {
171
+ await expect(
172
+ caller.verifyEmailOtp({ email: EMAIL_UNVERIFIED, otp: "000000" }),
173
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
174
+ });
175
+
176
+ it("throws BAD_REQUEST for expired OTP", async () => {
177
+ await db.delete(schema.verification).where(
178
+ eq(schema.verification.identifier, `email-verification:${EMAIL_VERIFIED}`),
179
+ );
180
+ await db.insert(schema.verification).values({
181
+ id: "v-otp-expired",
182
+ identifier: `email-verification:${EMAIL_VERIFIED}`,
183
+ value: "654321",
184
+ expiresAt: new Date(Date.now() - 1000), // already expired
185
+ });
186
+
187
+ await expect(
188
+ caller.verifyEmailOtp({ email: EMAIL_VERIFIED, otp: "654321" }),
189
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
190
+ });
191
+ });
192
+
193
+ // ── sendPasswordResetOtp ──────────────────────────────────────────────────
194
+
195
+ describe("sendPasswordResetOtp", () => {
196
+ it("sends reset email for registered user", async () => {
197
+ mockSendOtpEmail.mockClear();
198
+ const result = await caller.sendPasswordResetOtp({ email: EMAIL_UNVERIFIED });
199
+ expect(result.success).toBe(true);
200
+ expect(mockSendOtpEmail).toHaveBeenCalledTimes(1);
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ const callArg = (mockSendOtpEmail.mock.calls[0] as any[])[0] as { to: string; type: string };
203
+ expect(callArg.to).toBe(EMAIL_UNVERIFIED);
204
+ expect(callArg.type).toBe("forget-password");
205
+ });
206
+
207
+ it("silently returns generic response for non-existent email", async () => {
208
+ mockSendOtpEmail.mockClear();
209
+ const result = await caller.sendPasswordResetOtp({ email: EMAIL_NONEXISTENT });
210
+ expect(result.success).toBe(true);
211
+ expect(mockSendOtpEmail).not.toHaveBeenCalled();
212
+ });
213
+
214
+ it("checks per-email and per-IP rate limits for reset", async () => {
215
+ mockCheckRateLimit.mockClear();
216
+ await caller.sendPasswordResetOtp({ email: EMAIL_UNVERIFIED });
217
+ const keys = mockCheckRateLimit.mock.calls.map((c: unknown[]) => (c[0] as { key: string }).key);
218
+ expect(keys).toContain(`otp-send:email:${EMAIL_UNVERIFIED}`);
219
+ expect(keys).toContain(`otp-send:ip:${TEST_IP}:reset`);
220
+ });
221
+ });
222
+
223
+ // ── resetPassword ─────────────────────────────────────────────────────────
224
+
225
+ describe("resetPassword", () => {
226
+ it("resets password with valid OTP", async () => {
227
+ const otp = "789012";
228
+ await db.delete(schema.verification).where(
229
+ eq(schema.verification.identifier, `forget-password:${EMAIL_UNVERIFIED}`),
230
+ );
231
+ await db.insert(schema.verification).values({
232
+ id: "v-reset-1",
233
+ identifier: `forget-password:${EMAIL_UNVERIFIED}`,
234
+ value: otp,
235
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000),
236
+ });
237
+
238
+ const result = await caller.resetPassword({
239
+ email: EMAIL_UNVERIFIED,
240
+ otp,
241
+ newPassword: "NewPass123",
242
+ });
243
+ expect(result.success).toBe(true);
244
+
245
+ // OTP record should be deleted
246
+ const remaining = await db
247
+ .select()
248
+ .from(schema.verification)
249
+ .where(eq(schema.verification.id, "v-reset-1"));
250
+ expect(remaining).toHaveLength(0);
251
+ });
252
+
253
+ it("throws BAD_REQUEST for invalid OTP", async () => {
254
+ await expect(
255
+ caller.resetPassword({ email: EMAIL_UNVERIFIED, otp: "000000", newPassword: "NewPass123" }),
256
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
257
+ });
258
+ });
259
+
260
+ // ── rate limit propagation ─────────────────────────────────────────────────
261
+
262
+ describe("rate limit enforcement", () => {
263
+ it("throws TOO_MANY_REQUESTS when checkRateLimit throws", async () => {
264
+ const { TRPCError } = await import("@trpc/server");
265
+ mockCheckRateLimit.mockImplementation(async () => {
266
+ throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many requests. Please try again later." });
267
+ });
268
+
269
+ await expect(
270
+ caller.sendVerificationOtp({ email: EMAIL_UNVERIFIED }),
271
+ ).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
272
+
273
+ // Restore no-op
274
+ mockCheckRateLimit.mockImplementation(async () => {});
275
+ });
276
+ });
277
+ });
packages/api/src/context.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { auth } from "@labas/auth";
2
  import type { Context as HonoContext } from "hono";
 
3
 
4
  export type CreateContextOptions = {
5
  context: HonoContext;
@@ -12,6 +13,7 @@ export async function createContext({ context }: CreateContextOptions) {
12
  return {
13
  auth: null,
14
  session,
 
15
  };
16
  }
17
 
 
1
  import { auth } from "@labas/auth";
2
  import type { Context as HonoContext } from "hono";
3
+ import { getClientIp } from "./lib/client-ip";
4
 
5
  export type CreateContextOptions = {
6
  context: HonoContext;
 
13
  return {
14
  auth: null,
15
  session,
16
+ ip: getClientIp(context),
17
  };
18
  }
19
 
packages/api/src/lib/client-ip.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Context as HonoContext } from "hono";
2
+
3
+ export function getClientIp(context: HonoContext): string | undefined {
4
+ const headers = context.req.raw.headers;
5
+ const cfIp = headers.get("cf-connecting-ip");
6
+ if (cfIp) return cfIp.trim();
7
+ const forwarded = headers.get("x-forwarded-for");
8
+ if (forwarded) {
9
+ const firstIp = forwarded.split(",")[0]?.trim();
10
+ if (firstIp) return firstIp;
11
+ }
12
+ const realIp = headers.get("x-real-ip");
13
+ if (realIp) return realIp.trim();
14
+ return undefined;
15
+ }
packages/api/src/lib/rate-limit.ts CHANGED
@@ -39,6 +39,8 @@ export interface RateLimitConfig {
39
  key: string;
40
  limit: number;
41
  windowMs: number;
 
 
42
  }
43
 
44
  export async function checkRateLimit(config: RateLimitConfig): Promise<void> {
@@ -56,10 +58,39 @@ export async function checkRateLimit(config: RateLimitConfig): Promise<void> {
56
  }
57
  } catch (err) {
58
  if (err instanceof TRPCError) throw err;
 
 
 
 
 
 
 
59
  logger.warn("[RATELIMIT] Redis unavailable, rate limit skipped", { error: (err as Error).message });
60
  }
61
  }
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  export async function checkDailyBudget(key: string, limit: number): Promise<boolean> {
64
  const r = getRedis();
65
  const now = new Date();
 
39
  key: string;
40
  limit: number;
41
  windowMs: number;
42
+ /** When true, throw a 503 if Redis is unavailable instead of silently skipping. */
43
+ strict?: boolean;
44
  }
45
 
46
  export async function checkRateLimit(config: RateLimitConfig): Promise<void> {
 
58
  }
59
  } catch (err) {
60
  if (err instanceof TRPCError) throw err;
61
+ if (config.strict) {
62
+ logger.error("[RATELIMIT] Redis unavailable, rejecting request (strict mode)", { error: (err as Error).message });
63
+ throw new TRPCError({
64
+ code: "INTERNAL_SERVER_ERROR",
65
+ message: "Service temporarily unavailable. Please try again.",
66
+ });
67
+ }
68
  logger.warn("[RATELIMIT] Redis unavailable, rate limit skipped", { error: (err as Error).message });
69
  }
70
  }
71
 
72
+ /**
73
+ * Returns true if the request is allowed, false if rate-limited.
74
+ * Used by non-tRPC contexts (e.g. Hono middleware) where throwing TRPCError is not appropriate.
75
+ * Fails open (returns true) when Redis is unavailable unless `strict` is set.
76
+ */
77
+ export async function checkRateLimitAllowed(config: RateLimitConfig): Promise<boolean> {
78
+ const r = getRedis();
79
+ const key = `ratelimit:${config.key}`;
80
+ const now = Date.now();
81
+ try {
82
+ const allowed = await r.eval(SCRIPT, 1, key, config.limit.toString(), config.windowMs.toString(), now.toString());
83
+ return allowed !== 0;
84
+ } catch (err) {
85
+ if (config.strict) {
86
+ logger.error("[RATELIMIT] Redis unavailable (strict mode)", { error: (err as Error).message });
87
+ return false;
88
+ }
89
+ logger.warn("[RATELIMIT] Redis unavailable, rate limit skipped", { error: (err as Error).message });
90
+ return true;
91
+ }
92
+ }
93
+
94
  export async function checkDailyBudget(key: string, limit: number): Promise<boolean> {
95
  const r = getRedis();
96
  const now = new Date();
packages/api/src/lib/turnstile.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TRPCError } from "@trpc/server";
2
+ import { env } from "@labas/env/server";
3
+ import { logger } from "@labas/api/logger";
4
+
5
+ /**
6
+ * Validates a Cloudflare Turnstile token server-side.
7
+ * No-ops when CLOUDFLARE_TURNSTILE_SECRET_KEY is not configured.
8
+ * Throws BAD_REQUEST if the token is missing or invalid when the key is set.
9
+ */
10
+ export async function validateTurnstileToken(token: string | undefined): Promise<void> {
11
+ const secretKey = env.CLOUDFLARE_TURNSTILE_SECRET_KEY;
12
+ if (!secretKey) return;
13
+
14
+ if (!token) {
15
+ throw new TRPCError({ code: "BAD_REQUEST", message: "CAPTCHA verification is required." });
16
+ }
17
+
18
+ try {
19
+ const res = await fetch("https://challenges.cloudflare.com/turnstile/v1/siteverify", {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ secret: secretKey, response: token }),
23
+ });
24
+ const data = (await res.json()) as { success: boolean; "error-codes"?: string[] };
25
+ if (!data.success) {
26
+ logger.warn("[TURNSTILE] Token verification failed", { errorCodes: data["error-codes"] });
27
+ throw new TRPCError({ code: "BAD_REQUEST", message: "CAPTCHA verification failed. Please try again." });
28
+ }
29
+ } catch (err) {
30
+ if (err instanceof TRPCError) throw err;
31
+ // Fail open if the Cloudflare API is unreachable — don't block legitimate users
32
+ logger.error("[TURNSTILE] Siteverify request failed, skipping", { error: (err as Error).message });
33
+ }
34
+ }
packages/api/src/routers/verification.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createDb } from "@labas/db";
2
  import { verification, user, account } from "@labas/db/schema/auth";
3
  import { TRPCError } from "@trpc/server";
4
  import bcrypt from "bcryptjs";
@@ -10,6 +10,7 @@ import { sendOtpEmail } from "../lib/email";
10
  import { autoRefillIfEligible } from "../lib/credit";
11
  import { publicProcedure, router } from "../index";
12
  import { checkRateLimit } from "../lib/rate-limit";
 
13
 
14
  const OTP_LENGTH = 6;
15
  const OTP_EXPIRY_MS = 5 * 60 * 1000;
@@ -24,12 +25,12 @@ function genericOtpResponse() {
24
 
25
  export const verificationRouter = router({
26
  sendVerificationOtp: publicProcedure
27
- .input(z.object({ email: z.string().email() }))
28
- .mutation(async ({ input }) => {
29
- await checkRateLimit({ key: `otp-send:email:${input.email}`, limit: 3, windowMs: 300_000 });
30
- await checkRateLimit({ key: `otp-send:ip:email`, limit: 20, windowMs: 900_000 });
31
-
32
- const db = createDb();
33
 
34
  const [existingUser] = await db
35
  .select({ id: user.id, emailVerified: user.emailVerified })
@@ -60,10 +61,10 @@ export const verificationRouter = router({
60
 
61
  verifyEmailOtp: publicProcedure
62
  .input(z.object({ email: z.string().email(), otp: z.string().length(6) }))
63
- .mutation(async ({ input }) => {
64
- await checkRateLimit({ key: `otp-verify:email:${input.email}`, limit: 10, windowMs: 300_000 });
65
-
66
- const db = createDb();
67
 
68
  const identifier = `email-verification:${input.email}`;
69
  const [record] = await db
@@ -103,12 +104,12 @@ export const verificationRouter = router({
103
  }),
104
 
105
  sendPasswordResetOtp: publicProcedure
106
- .input(z.object({ email: z.string().email() }))
107
- .mutation(async ({ input }) => {
108
- await checkRateLimit({ key: `otp-send:email:${input.email}`, limit: 3, windowMs: 300_000 });
109
- await checkRateLimit({ key: `otp-send:ip:reset`, limit: 20, windowMs: 900_000 });
110
-
111
- const db = createDb();
112
 
113
  const [existingUser] = await db
114
  .select({ id: user.id })
@@ -145,10 +146,10 @@ export const verificationRouter = router({
145
  newPassword: z.string().regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/, "Password must be at least 8 characters with uppercase, lowercase, and number"),
146
  }),
147
  )
148
- .mutation(async ({ input }) => {
149
- await checkRateLimit({ key: `otp-reset:email:${input.email}`, limit: 5, windowMs: 300_000 });
150
-
151
- const db = createDb();
152
 
153
  const identifier = `forget-password:${input.email}`;
154
  const [record] = await db
 
1
+ import { db } from "@labas/db";
2
  import { verification, user, account } from "@labas/db/schema/auth";
3
  import { TRPCError } from "@trpc/server";
4
  import bcrypt from "bcryptjs";
 
10
  import { autoRefillIfEligible } from "../lib/credit";
11
  import { publicProcedure, router } from "../index";
12
  import { checkRateLimit } from "../lib/rate-limit";
13
+ import { validateTurnstileToken } from "../lib/turnstile";
14
 
15
  const OTP_LENGTH = 6;
16
  const OTP_EXPIRY_MS = 5 * 60 * 1000;
 
25
 
26
  export const verificationRouter = router({
27
  sendVerificationOtp: publicProcedure
28
+ .input(z.object({ email: z.string().email(), turnstileToken: z.string().optional() }))
29
+ .mutation(async ({ input, ctx }) => {
30
+ const ip = ctx.ip ?? "unknown";
31
+ await checkRateLimit({ key: `otp-send:email:${input.email}`, limit: 3, windowMs: 300_000, strict: true });
32
+ await checkRateLimit({ key: `otp-send:ip:${ip}:email`, limit: 20, windowMs: 900_000, strict: true });
33
+ await validateTurnstileToken(input.turnstileToken);
34
 
35
  const [existingUser] = await db
36
  .select({ id: user.id, emailVerified: user.emailVerified })
 
61
 
62
  verifyEmailOtp: publicProcedure
63
  .input(z.object({ email: z.string().email(), otp: z.string().length(6) }))
64
+ .mutation(async ({ input, ctx }) => {
65
+ const ip = ctx.ip ?? "unknown";
66
+ await checkRateLimit({ key: `otp-verify:email:${input.email}`, limit: 10, windowMs: 300_000, strict: true });
67
+ await checkRateLimit({ key: `otp-verify:ip:${ip}`, limit: 30, windowMs: 300_000, strict: true });
68
 
69
  const identifier = `email-verification:${input.email}`;
70
  const [record] = await db
 
104
  }),
105
 
106
  sendPasswordResetOtp: publicProcedure
107
+ .input(z.object({ email: z.string().email(), turnstileToken: z.string().optional() }))
108
+ .mutation(async ({ input, ctx }) => {
109
+ const ip = ctx.ip ?? "unknown";
110
+ await checkRateLimit({ key: `otp-send:email:${input.email}`, limit: 3, windowMs: 300_000, strict: true });
111
+ await checkRateLimit({ key: `otp-send:ip:${ip}:reset`, limit: 20, windowMs: 900_000, strict: true });
112
+ await validateTurnstileToken(input.turnstileToken);
113
 
114
  const [existingUser] = await db
115
  .select({ id: user.id })
 
146
  newPassword: z.string().regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/, "Password must be at least 8 characters with uppercase, lowercase, and number"),
147
  }),
148
  )
149
+ .mutation(async ({ input, ctx }) => {
150
+ const ip = ctx.ip ?? "unknown";
151
+ await checkRateLimit({ key: `otp-reset:email:${input.email}`, limit: 5, windowMs: 300_000, strict: true });
152
+ await checkRateLimit({ key: `otp-reset:ip:${ip}`, limit: 15, windowMs: 300_000, strict: true });
153
 
154
  const identifier = `forget-password:${input.email}`;
155
  const [record] = await db
packages/env/src/server.ts CHANGED
@@ -22,6 +22,7 @@ export const env = createEnv({
22
  FREE_CREDITS_ENABLED: z.coerce.boolean().default(false),
23
  FREE_CREDITS_MAX_POOL: z.coerce.number().positive().default(1_000_000),
24
  DEFAULT_SIGNUP_CREDIT_TOKENS: z.coerce.number().positive().default(50_000),
 
25
  },
26
  runtimeEnv: process.env,
27
  emptyStringAsUndefined: true,
 
22
  FREE_CREDITS_ENABLED: z.coerce.boolean().default(false),
23
  FREE_CREDITS_MAX_POOL: z.coerce.number().positive().default(1_000_000),
24
  DEFAULT_SIGNUP_CREDIT_TOKENS: z.coerce.number().positive().default(50_000),
25
+ CLOUDFLARE_TURNSTILE_SECRET_KEY: z.string().optional(),
26
  },
27
  runtimeEnv: process.env,
28
  emptyStringAsUndefined: true,
packages/env/src/web.ts CHANGED
@@ -6,6 +6,7 @@ export const env = createEnv({
6
  client: {
7
  VITE_SERVER_URL: z.url(),
8
  VITE_SITE_URL: z.url().default("http://localhost:5173"),
 
9
  },
10
  runtimeEnv: (import.meta as any).env,
11
  emptyStringAsUndefined: true,
 
6
  client: {
7
  VITE_SERVER_URL: z.url(),
8
  VITE_SITE_URL: z.url().default("http://localhost:5173"),
9
+ VITE_CLOUDFLARE_TURNSTILE_SITE_KEY: z.string().optional(),
10
  },
11
  runtimeEnv: (import.meta as any).env,
12
  emptyStringAsUndefined: true,