@contentgrowth/content-auth 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,47 @@ import { Context, Hono } from 'hono';
6
6
  export { Hono } from 'hono';
7
7
  import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
8
8
 
9
+ /**
10
+ * Cloudflare Turnstile Verification Utility
11
+ *
12
+ * Verifies Turnstile tokens on the server-side using Cloudflare's Siteverify API.
13
+ * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
14
+ */
15
+ interface VerifyResult {
16
+ success: boolean;
17
+ error?: string;
18
+ hostname?: string;
19
+ }
20
+ /**
21
+ * Verifies a Turnstile token with Cloudflare's Siteverify API.
22
+ *
23
+ * @param secretKey - Your Turnstile secret key
24
+ * @param token - The token from the client-side Turnstile widget (cf-turnstile-response)
25
+ * @param remoteIp - Optional: The visitor's IP address for additional validation
26
+ * @returns Verification result with success status and any error messages
27
+ */
28
+ declare function verifyTurnstile(secretKey: string, token: string, remoteIp?: string): Promise<VerifyResult>;
29
+
30
+ /**
31
+ * Email Normalization Utilities
32
+ *
33
+ * Normalizes email addresses to prevent duplicate accounts through
34
+ * the "Gmail dot trick" and plus-addressing.
35
+ */
36
+ /**
37
+ * Normalizes an email address for duplicate detection.
38
+ * - Gmail addresses: strips dots and plus-addressing, normalizes googlemail.com
39
+ * - Other addresses: lowercases only
40
+ *
41
+ * @param email - The email address to normalize
42
+ * @returns The normalized email address
43
+ */
44
+ declare function normalizeEmail(email: string): string;
45
+ /**
46
+ * Checks if an email is a Gmail address (including googlemail.com)
47
+ */
48
+ declare function isGmailAddress(email: string): boolean;
49
+
9
50
  declare const users: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
10
51
  name: "users";
11
52
  schema: undefined;
@@ -1084,6 +1125,16 @@ declare function getInvitationLink(data: any, baseUrl: string): {
1084
1125
  */
1085
1126
  declare function getSessionToken(req: Request): string | null;
1086
1127
 
1128
+ interface TurnstileConfig {
1129
+ /** Cloudflare Turnstile secret key */
1130
+ secretKey: string;
1131
+ }
1132
+ interface EmailNormalizationConfig {
1133
+ /** Enable email normalization for duplicate detection. Default: false */
1134
+ enabled: boolean;
1135
+ /** Column name in users table. Default: 'normalized_email' */
1136
+ columnName?: string;
1137
+ }
1087
1138
  interface AuthConfig {
1088
1139
  /**
1089
1140
  * The database instance or D1 binding.
@@ -1121,6 +1172,16 @@ interface AuthConfig {
1121
1172
  token: string;
1122
1173
  }, request: any) => Promise<void> | void;
1123
1174
  };
1175
+ /**
1176
+ * Cloudflare Turnstile configuration for bot protection on email signups.
1177
+ * When configured, the AuthForm frontend will show a Turnstile challenge.
1178
+ */
1179
+ turnstile?: TurnstileConfig;
1180
+ /**
1181
+ * Email normalization for duplicate prevention (Gmail dot trick, plus-addressing).
1182
+ * Requires a 'normalized_email' column in the users table.
1183
+ */
1184
+ emailNormalization?: EmailNormalizationConfig;
1124
1185
  [key: string]: any;
1125
1186
  }
1126
1187
  declare const createAuth: (config: AuthConfig) => better_auth.Auth<any>;
@@ -1130,4 +1191,4 @@ declare const createAuthApp: (config: AuthConfig) => {
1130
1191
  auth: better_auth.Auth<any>;
1131
1192
  };
1132
1193
 
1133
- export { type AuthConfig, authMiddleware, createAuth, createAuthApp, getInvitationLink, getSessionToken, schema };
1194
+ export { type AuthConfig, type EmailNormalizationConfig, type TurnstileConfig, authMiddleware, createAuth, createAuthApp, getInvitationLink, getSessionToken, isGmailAddress, normalizeEmail, schema, verifyTurnstile };
@@ -5,8 +5,11 @@ import {
5
5
  createAuthApp,
6
6
  getInvitationLink,
7
7
  getSessionToken,
8
- schema_exports
9
- } from "../chunk-N5OK3XPK.js";
8
+ isGmailAddress,
9
+ normalizeEmail,
10
+ schema_exports,
11
+ verifyTurnstile
12
+ } from "../chunk-H37QRFRH.js";
10
13
  import "../chunk-R5U7XKVJ.js";
11
14
  export {
12
15
  Hono,
@@ -15,5 +18,8 @@ export {
15
18
  createAuthApp,
16
19
  getInvitationLink,
17
20
  getSessionToken,
18
- schema_exports as schema
21
+ isGmailAddress,
22
+ normalizeEmail,
23
+ schema_exports as schema,
24
+ verifyTurnstile
19
25
  };
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-NKDKDBM2.js";
4
4
 
5
5
  // src/frontend/components/AuthForm.tsx
6
- import React, { useState } from "react";
6
+ import { useState, useRef, useEffect } from "react";
7
7
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
8
  var AuthForm = ({
9
9
  client = authClient,
@@ -19,7 +19,8 @@ var AuthForm = ({
19
19
  onSwitchMode,
20
20
  defaultEmail = "",
21
21
  lockEmail = false,
22
- forgotPasswordUrl
22
+ forgotPasswordUrl,
23
+ turnstileSiteKey
23
24
  }) => {
24
25
  const [isLogin, setIsLogin] = useState(view !== "signup");
25
26
  const [email, setEmail] = useState(defaultEmail);
@@ -27,13 +28,37 @@ var AuthForm = ({
27
28
  const [name, setName] = useState("");
28
29
  const [loading, setLoading] = useState(false);
29
30
  const [error, setError] = useState(null);
31
+ const [turnstileToken, setTurnstileToken] = useState(null);
32
+ const turnstileRef = useRef(null);
33
+ const [TurnstileComponent, setTurnstileComponent] = useState(null);
30
34
  const [mounted, setMounted] = useState(false);
31
- React.useEffect(() => {
35
+ useEffect(() => {
32
36
  setMounted(true);
33
37
  setIsLogin(view !== "signup");
34
- }, [view]);
38
+ if (turnstileSiteKey) {
39
+ import("@marsidev/react-turnstile").then((module) => setTurnstileComponent(() => module.Turnstile)).catch(() => setTurnstileComponent(null));
40
+ }
41
+ }, [view, turnstileSiteKey]);
42
+ const turnstileEnabled = turnstileSiteKey && TurnstileComponent;
43
+ const turnstileRequired = turnstileEnabled && !isLogin;
44
+ const canSubmit = !turnstileRequired || !!turnstileToken;
45
+ const handleTurnstileSuccess = (token) => {
46
+ setTurnstileToken(token);
47
+ };
48
+ const handleTurnstileError = () => {
49
+ setTurnstileToken(null);
50
+ setError("Security verification failed. Please try again.");
51
+ };
52
+ const handleTurnstileExpire = () => {
53
+ setTurnstileToken(null);
54
+ turnstileRef.current?.reset();
55
+ };
35
56
  const handleSubmit = async (e) => {
36
57
  e.preventDefault();
58
+ if (turnstileRequired && !turnstileToken) {
59
+ setError("Please complete the security challenge");
60
+ return;
61
+ }
37
62
  setLoading(true);
38
63
  setError(null);
39
64
  try {
@@ -45,16 +70,24 @@ var AuthForm = ({
45
70
  });
46
71
  if (response.error) throw response.error;
47
72
  } else {
48
- response = await client.signUp.email({
73
+ const signupData = {
49
74
  email,
50
75
  password,
51
76
  name
52
- });
77
+ };
78
+ if (turnstileToken) {
79
+ signupData.turnstileToken = turnstileToken;
80
+ }
81
+ response = await client.signUp.email(signupData);
53
82
  if (response.error) throw response.error;
54
83
  }
55
84
  onSuccess?.(response.data);
56
85
  } catch (err) {
57
86
  setError(err.message || "An error occurred");
87
+ if (turnstileRef.current) {
88
+ turnstileRef.current.reset();
89
+ setTurnstileToken(null);
90
+ }
58
91
  } finally {
59
92
  setLoading(false);
60
93
  }
@@ -77,6 +110,26 @@ var AuthForm = ({
77
110
  else if (width === "wide") widthClass = "ca-width-wide";
78
111
  else widthClass = "ca-width-default";
79
112
  const containerClass = `ca-container ${layout === "split" ? "ca-layout-split" : ""} ${widthClass} ${className || ""}`;
113
+ const renderTurnstile = () => {
114
+ if (!turnstileEnabled || isLogin) return null;
115
+ return /* @__PURE__ */ jsxs("div", { className: "ca-turnstile", children: [
116
+ /* @__PURE__ */ jsx(
117
+ TurnstileComponent,
118
+ {
119
+ ref: turnstileRef,
120
+ siteKey: turnstileSiteKey,
121
+ onSuccess: handleTurnstileSuccess,
122
+ onError: handleTurnstileError,
123
+ onExpire: handleTurnstileExpire,
124
+ options: {
125
+ theme: "light",
126
+ size: "normal"
127
+ }
128
+ }
129
+ ),
130
+ !turnstileToken && /* @__PURE__ */ jsx("p", { className: "ca-turnstile-hint", children: "Please complete the security check above" })
131
+ ] });
132
+ };
80
133
  const renderSocials = () => (
81
134
  // Hide social logins when email is locked (e.g., invitation flow)
82
135
  !lockEmail && socialProviders.length > 0 && /* @__PURE__ */ jsx("div", { className: socialClass, children: socialProviders.map((provider) => /* @__PURE__ */ jsxs(
@@ -154,8 +207,17 @@ var AuthForm = ({
154
207
  }
155
208
  )
156
209
  ] }),
210
+ renderTurnstile(),
157
211
  error && /* @__PURE__ */ jsx("div", { className: "ca-error", children: error }),
158
- /* @__PURE__ */ jsx("button", { type: "submit", className: "ca-button", disabled: loading, children: loading ? "Loading..." : isLogin ? "Sign In" : "Sign Up" })
212
+ /* @__PURE__ */ jsx(
213
+ "button",
214
+ {
215
+ type: "submit",
216
+ className: "ca-button",
217
+ disabled: loading || !canSubmit,
218
+ children: loading ? "Loading..." : isLogin ? "Sign In" : "Sign Up"
219
+ }
220
+ )
159
221
  ] });
160
222
  };
161
223
  const renderFooter = () => /* @__PURE__ */ jsxs("div", { className: "ca-footer", children: [
@@ -170,6 +232,10 @@ var AuthForm = ({
170
232
  } else {
171
233
  setIsLogin(!isLogin);
172
234
  }
235
+ if (turnstileRef.current) {
236
+ turnstileRef.current.reset();
237
+ setTurnstileToken(null);
238
+ }
173
239
  },
174
240
  type: "button",
175
241
  children: isLogin ? "Sign up" : "Sign in"
@@ -411,7 +477,7 @@ var ResetPasswordForm = ({
411
477
  };
412
478
 
413
479
  // src/frontend/components/Organization.tsx
414
- import { useState as useState4, useEffect } from "react";
480
+ import { useState as useState4, useEffect as useEffect2 } from "react";
415
481
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
416
482
  var CreateOrganizationForm = ({
417
483
  client = authClient,
@@ -484,7 +550,7 @@ var OrganizationSwitcher = ({
484
550
  }) => {
485
551
  const [orgs, setOrgs] = useState4([]);
486
552
  const [loading, setLoading] = useState4(true);
487
- useEffect(() => {
553
+ useEffect2(() => {
488
554
  const fetchOrgs = async () => {
489
555
  const { data } = await client.organization.list({});
490
556
  if (data) setOrgs(data);
@@ -576,7 +642,7 @@ var InviteMemberForm = ({
576
642
  };
577
643
 
578
644
  // src/frontend/components/ProfileEditor.tsx
579
- import { useState as useState5, useEffect as useEffect2 } from "react";
645
+ import { useState as useState5, useEffect as useEffect3 } from "react";
580
646
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
581
647
  var ProfileEditor = ({
582
648
  client = authClient,
@@ -589,7 +655,7 @@ var ProfileEditor = ({
589
655
  const [name, setName] = useState5(defaultName);
590
656
  const [image, setImage] = useState5(defaultImage);
591
657
  const [loading, setLoading] = useState5(false);
592
- useEffect2(() => {
658
+ useEffect3(() => {
593
659
  if (defaultName) setName(defaultName);
594
660
  if (defaultImage) setImage(defaultImage);
595
661
  }, [defaultName, defaultImage]);
@@ -661,7 +727,7 @@ var ProfileEditor = ({
661
727
  };
662
728
 
663
729
  // src/frontend/components/PasswordChanger.tsx
664
- import React6, { useState as useState6 } from "react";
730
+ import { useState as useState6 } from "react";
665
731
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
666
732
  var PasswordChanger = ({
667
733
  client = authClient,
@@ -674,26 +740,6 @@ var PasswordChanger = ({
674
740
  const [newPassword, setNewPassword] = useState6("");
675
741
  const [confirmPassword, setConfirmPassword] = useState6("");
676
742
  const [loading, setLoading] = useState6(false);
677
- const [canChangePassword, setCanChangePassword] = useState6(true);
678
- const [authMessage, setAuthMessage] = useState6(null);
679
- React6.useEffect(() => {
680
- const checkAccounts = async () => {
681
- try {
682
- const accounts = await client.listAccounts();
683
- if (accounts?.data) {
684
- const hasCredential = accounts.data.some((acc) => acc.providerId === "credential");
685
- if (!hasCredential) {
686
- setCanChangePassword(false);
687
- const providers = accounts.data.map((acc) => acc.providerId).join(", ");
688
- setAuthMessage(`You are signed in via ${providers}. You cannot change your password here because you don't have a password set.`);
689
- }
690
- }
691
- } catch (e) {
692
- console.warn("Failed to list accounts", e);
693
- }
694
- };
695
- checkAccounts();
696
- }, [client]);
697
743
  const handleSubmit = async (e) => {
698
744
  e.preventDefault();
699
745
  if (newPassword !== confirmPassword) {
@@ -715,9 +761,7 @@ var PasswordChanger = ({
715
761
  onSuccess?.(res?.data);
716
762
  } catch (err) {
717
763
  if (err?.code === "CREDENTIAL_ACCOUNT_NOT_FOUND" || err?.message?.includes("Credential account not found")) {
718
- setCanChangePassword(false);
719
- setAuthMessage("You are logged in via a social provider and do not have a password set.");
720
- onError?.("Password change unavailable for social logins.");
764
+ onError?.("You are logged in via a social provider (e.g. GitHub, Google) and do not have a password set.");
721
765
  } else {
722
766
  onError?.(err.message || "Failed to change password");
723
767
  }
@@ -725,12 +769,6 @@ var PasswordChanger = ({
725
769
  setLoading(false);
726
770
  }
727
771
  };
728
- if (!canChangePassword) {
729
- return /* @__PURE__ */ jsx6("div", { className: `ca-form ${className || ""}`, children: /* @__PURE__ */ jsxs6("div", { className: "ca-info-message", style: { textAlign: "center", padding: "2rem", background: "#f9fafb", borderRadius: "8px", border: "1px solid #e5e7eb" }, children: [
730
- /* @__PURE__ */ jsx6("p", { style: { color: "#6b7280", marginBottom: "0.5rem" }, children: "Password Change Unavailable" }),
731
- /* @__PURE__ */ jsx6("p", { style: { color: "#374151", fontSize: "0.95rem" }, children: authMessage || "Your account is managed by a third-party provider." })
732
- ] }) });
733
- }
734
772
  return /* @__PURE__ */ jsxs6("form", { onSubmit: handleSubmit, className: `ca-form ${className || ""}`, children: [
735
773
  /* @__PURE__ */ jsxs6("div", { className: "ca-input-group", children: [
736
774
  /* @__PURE__ */ jsx6("label", { className: "ca-label", htmlFor: "current-password", children: "Current Password" }),
@@ -9,6 +9,87 @@ import { drizzle } from "drizzle-orm/d1";
9
9
  import { Hono } from "hono";
10
10
  export * from "better-auth";
11
11
 
12
+ // src/backend/turnstile.ts
13
+ async function verifyTurnstile(secretKey, token, remoteIp) {
14
+ if (!secretKey) {
15
+ console.warn("[Turnstile] No secret key configured, skipping verification");
16
+ return { success: true };
17
+ }
18
+ if (!token) {
19
+ return { success: false, error: "Missing Turnstile token" };
20
+ }
21
+ try {
22
+ const formData = new URLSearchParams();
23
+ formData.append("secret", secretKey);
24
+ formData.append("response", token);
25
+ if (remoteIp) {
26
+ formData.append("remoteip", remoteIp);
27
+ }
28
+ const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
29
+ method: "POST",
30
+ headers: {
31
+ "Content-Type": "application/x-www-form-urlencoded"
32
+ },
33
+ body: formData.toString()
34
+ });
35
+ if (!response.ok) {
36
+ console.error(`[Turnstile] Siteverify API error: ${response.status}`);
37
+ return { success: false, error: `API error: ${response.status}` };
38
+ }
39
+ const result = await response.json();
40
+ if (result.success) {
41
+ return { success: true, hostname: result.hostname };
42
+ } else {
43
+ const errorCodes = result["error-codes"] || [];
44
+ console.warn(`[Turnstile] Verification failed: ${errorCodes.join(", ")}`);
45
+ return {
46
+ success: false,
47
+ error: mapTurnstileError(errorCodes)
48
+ };
49
+ }
50
+ } catch (error) {
51
+ console.error(`[Turnstile] Verification error: ${error.message}`);
52
+ return { success: false, error: "Verification service unavailable" };
53
+ }
54
+ }
55
+ function mapTurnstileError(codes) {
56
+ if (codes.includes("timeout-or-duplicate")) {
57
+ return "Challenge expired or already used. Please try again.";
58
+ }
59
+ if (codes.includes("invalid-input-response")) {
60
+ return "Invalid challenge response. Please try again.";
61
+ }
62
+ if (codes.includes("bad-request")) {
63
+ return "Invalid request. Please refresh and try again.";
64
+ }
65
+ if (codes.includes("internal-error")) {
66
+ return "Verification service error. Please try again later.";
67
+ }
68
+ return "Challenge verification failed. Please try again.";
69
+ }
70
+
71
+ // src/backend/email-normalize.ts
72
+ function normalizeEmail(email) {
73
+ const lowerEmail = email.toLowerCase().trim();
74
+ const atIndex = lowerEmail.indexOf("@");
75
+ if (atIndex === -1) {
76
+ return lowerEmail;
77
+ }
78
+ const local = lowerEmail.substring(0, atIndex);
79
+ const domain = lowerEmail.substring(atIndex + 1);
80
+ const normalizedDomain = domain === "googlemail.com" ? "gmail.com" : domain;
81
+ if (normalizedDomain === "gmail.com") {
82
+ const localWithoutPlus = local.split("+")[0];
83
+ const normalizedLocal = localWithoutPlus.replace(/\./g, "");
84
+ return `${normalizedLocal}@gmail.com`;
85
+ }
86
+ return lowerEmail;
87
+ }
88
+ function isGmailAddress(email) {
89
+ const lowerEmail = email.toLowerCase();
90
+ return lowerEmail.endsWith("@gmail.com") || lowerEmail.endsWith("@googlemail.com");
91
+ }
92
+
12
93
  // src/backend/schema.ts
13
94
  var schema_exports = {};
14
95
  __export(schema_exports, {
@@ -195,13 +276,24 @@ function getSessionToken(req) {
195
276
  // src/backend/index.ts
196
277
  var createAuth = (config) => {
197
278
  let db;
279
+ let rawDb = config.database;
198
280
  let provider = config.provider || "sqlite";
199
281
  if (config.database && typeof config.database.prepare === "function") {
200
282
  db = drizzle(config.database, { schema: schema_exports });
201
283
  } else {
202
284
  db = config.database;
203
285
  }
204
- const { database, secret, baseUrl, provider: _, useCloudflareNativeHashing = true, emailVerification, ...rest } = config;
286
+ const {
287
+ database,
288
+ secret,
289
+ baseUrl,
290
+ provider: _,
291
+ useCloudflareNativeHashing = true,
292
+ emailVerification,
293
+ turnstile: turnstileConfig,
294
+ emailNormalization,
295
+ ...rest
296
+ } = config;
205
297
  let adapterOptions = {
206
298
  provider,
207
299
  schema: {
@@ -215,7 +307,7 @@ var createAuth = (config) => {
215
307
  }
216
308
  };
217
309
  const emailConfig = rest.emailAndPassword || { enabled: true };
218
- const { emailAndPassword, ...otherOptions } = rest;
310
+ const { emailAndPassword, hooks: userHooks, ...otherOptions } = rest;
219
311
  const emailPasswordOptions = {
220
312
  ...emailConfig
221
313
  };
@@ -225,6 +317,99 @@ var createAuth = (config) => {
225
317
  verify: verifyPassword
226
318
  };
227
319
  }
320
+ const normalizedEmailColumn = emailNormalization?.columnName || "normalized_email";
321
+ const contentAuthHooks = {
322
+ before: async (context) => {
323
+ const path = context.path || "";
324
+ if (path.includes("/sign-up/email")) {
325
+ try {
326
+ let body;
327
+ if (context.body) {
328
+ body = context.body;
329
+ } else {
330
+ try {
331
+ body = await context.request.clone().json();
332
+ } catch (e) {
333
+ }
334
+ }
335
+ if (body) {
336
+ if (turnstileConfig?.secretKey) {
337
+ const turnstileToken = body.turnstileToken;
338
+ if (!turnstileToken) {
339
+ console.warn("[ContentAuth] Email signup missing Turnstile token");
340
+ return {
341
+ status: 400,
342
+ body: JSON.stringify({
343
+ success: false,
344
+ code: "TURNSTILE_REQUIRED",
345
+ message: "Please complete the security challenge"
346
+ }),
347
+ headers: { "Content-Type": "application/json" }
348
+ };
349
+ }
350
+ const { success, error } = await verifyTurnstile(turnstileConfig.secretKey, turnstileToken);
351
+ if (!success) {
352
+ console.warn(`[ContentAuth] Turnstile verification failed: ${error}`);
353
+ return {
354
+ status: 400,
355
+ body: JSON.stringify({
356
+ success: false,
357
+ code: "TURNSTILE_FAILED",
358
+ message: error || "Security challenge failed. Please try again."
359
+ }),
360
+ headers: { "Content-Type": "application/json" }
361
+ };
362
+ }
363
+ }
364
+ if (emailNormalization?.enabled && body.email && rawDb?.prepare) {
365
+ const normalized = normalizeEmail(body.email);
366
+ const existing = await rawDb.prepare(
367
+ `SELECT id FROM users WHERE ${normalizedEmailColumn} = ?`
368
+ ).bind(normalized).first();
369
+ if (existing) {
370
+ console.warn(`[ContentAuth] Duplicate normalized email detected: ${normalized}`);
371
+ return {
372
+ status: 400,
373
+ body: JSON.stringify({
374
+ success: false,
375
+ code: "EMAIL_EXISTS",
376
+ message: "An account with this email already exists"
377
+ }),
378
+ headers: { "Content-Type": "application/json" }
379
+ };
380
+ }
381
+ }
382
+ }
383
+ } catch (e) {
384
+ console.error("[ContentAuth] Email signup hook error:", e.message);
385
+ }
386
+ }
387
+ if (userHooks?.before) {
388
+ return userHooks.before(context);
389
+ }
390
+ return;
391
+ },
392
+ after: async (context) => {
393
+ const path = context.path || "";
394
+ const user = context.user || context.response?.user || context.data?.user || context.context?.returned?.user || context.context?.newSession?.user;
395
+ if (emailNormalization?.enabled && rawDb?.prepare) {
396
+ if ((path.includes("/sign-up") || path.includes("/callback")) && user?.id && user?.email) {
397
+ try {
398
+ const normalized = normalizeEmail(user.email);
399
+ await rawDb.prepare(
400
+ `UPDATE users SET ${normalizedEmailColumn} = ? WHERE id = ? AND (${normalizedEmailColumn} IS NULL OR ${normalizedEmailColumn} != ?)`
401
+ ).bind(normalized, user.id, normalized).run();
402
+ } catch (e) {
403
+ console.error(`[ContentAuth] Failed to set normalized_email: ${e.message}`);
404
+ }
405
+ }
406
+ }
407
+ if (userHooks?.after) {
408
+ return userHooks.after(context);
409
+ }
410
+ return {};
411
+ }
412
+ };
228
413
  const auth = betterAuth({
229
414
  database: drizzleAdapter(db, adapterOptions),
230
415
  secret,
@@ -232,6 +417,8 @@ var createAuth = (config) => {
232
417
  emailAndPassword: emailPasswordOptions,
233
418
  // Pass emailVerification config if provided
234
419
  ...emailVerification ? { emailVerification } : {},
420
+ // Merge content-auth hooks with user hooks
421
+ hooks: contentAuthHooks,
235
422
  ...otherOptions
236
423
  });
237
424
  return auth;
@@ -251,6 +438,9 @@ var createAuthApp = (config) => {
251
438
  };
252
439
 
253
440
  export {
441
+ verifyTurnstile,
442
+ normalizeEmail,
443
+ isGmailAddress,
254
444
  schema_exports,
255
445
  getInvitationLink,
256
446
  getSessionToken,
@@ -24,6 +24,8 @@ interface AuthFormProps {
24
24
  lockEmail?: boolean;
25
25
  /** URL for the forgot password page (shows link on login form if provided) */
26
26
  forgotPasswordUrl?: string;
27
+ /** Cloudflare Turnstile site key. When set, shows Turnstile widget on signup. */
28
+ turnstileSiteKey?: string;
27
29
  }
28
30
  declare const AuthForm: React.FC<AuthFormProps>;
29
31
 
@@ -7,7 +7,7 @@ import {
7
7
  PasswordChanger,
8
8
  ProfileEditor,
9
9
  ResetPasswordForm
10
- } from "../chunk-EU3UKKTM.js";
10
+ } from "../chunk-6MYNRK2N.js";
11
11
  import {
12
12
  authClient,
13
13
  createClient
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { AuthConfig, authMiddleware, createAuth, createAuthApp, getInvitationLink, getSessionToken, schema } from './backend/index.js';
1
+ export { AuthConfig, EmailNormalizationConfig, TurnstileConfig, authMiddleware, createAuth, createAuthApp, getInvitationLink, getSessionToken, isGmailAddress, normalizeEmail, schema, verifyTurnstile } from './backend/index.js';
2
2
  export { AuthForm, CreateOrganizationForm, ForgotPasswordForm, InviteMemberForm, OrganizationSwitcher, PasswordChanger, PasswordChangerProps, ProfileEditor, ProfileEditorProps, ResetPasswordForm } from './frontend/index.js';
3
3
  export { authClient, createClient } from './frontend/client.js';
4
4
  export * from 'better-auth';
package/dist/index.js CHANGED
@@ -5,8 +5,11 @@ import {
5
5
  createAuthApp,
6
6
  getInvitationLink,
7
7
  getSessionToken,
8
- schema_exports
9
- } from "./chunk-N5OK3XPK.js";
8
+ isGmailAddress,
9
+ normalizeEmail,
10
+ schema_exports,
11
+ verifyTurnstile
12
+ } from "./chunk-H37QRFRH.js";
10
13
  import {
11
14
  AuthForm,
12
15
  CreateOrganizationForm,
@@ -16,7 +19,7 @@ import {
16
19
  PasswordChanger,
17
20
  ProfileEditor,
18
21
  ResetPasswordForm
19
- } from "./chunk-EU3UKKTM.js";
22
+ } from "./chunk-6MYNRK2N.js";
20
23
  import {
21
24
  authClient,
22
25
  createClient
@@ -39,5 +42,8 @@ export {
39
42
  createClient,
40
43
  getInvitationLink,
41
44
  getSessionToken,
42
- schema_exports as schema
45
+ isGmailAddress,
46
+ normalizeEmail,
47
+ schema_exports as schema,
48
+ verifyTurnstile
43
49
  };
package/dist/styles.css CHANGED
@@ -346,4 +346,27 @@ button[type="submit"]:disabled {
346
346
  flex-direction: column;
347
347
  justify-content: center;
348
348
  }
349
+ }
350
+
351
+ /* Turnstile Widget */
352
+ .ca-turnstile {
353
+ display: flex;
354
+ flex-direction: column;
355
+ align-items: center;
356
+ gap: 0.5rem;
357
+ margin: 0.5rem 0;
358
+ }
359
+
360
+ .ca-turnstile-hint {
361
+ font-size: 0.8125rem;
362
+ color: #6b7280;
363
+ text-align: center;
364
+ margin: 0;
365
+ }
366
+
367
+ /* Locked Input */
368
+ .ca-input-locked {
369
+ background-color: #f3f4f6;
370
+ color: #6b7280;
371
+ cursor: not-allowed;
349
372
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@contentgrowth/content-auth",
3
- "version": "0.2.9",
4
- "description": "Better Auth wrapper with UI components for Cloudflare Workers & Pages",
3
+ "version": "0.3.1",
4
+ "description": "Better Auth wrapper with UI components for Cloudflare Workers & Pages. Includes Turnstile bot protection and email normalization.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -50,9 +50,15 @@
50
50
  "author": "Content Growth",
51
51
  "license": "MIT",
52
52
  "peerDependencies": {
53
+ "@marsidev/react-turnstile": ">=0.5.0",
53
54
  "react": ">=18.2.0",
54
55
  "react-dom": ">=18.2.0"
55
56
  },
57
+ "peerDependenciesMeta": {
58
+ "@marsidev/react-turnstile": {
59
+ "optional": true
60
+ }
61
+ },
56
62
  "dependencies": {
57
63
  "better-auth": "^1.4.9",
58
64
  "cac": "^6.7.14",
@@ -61,6 +67,7 @@
61
67
  },
62
68
  "devDependencies": {
63
69
  "@cloudflare/workers-types": "^4.20251231.0",
70
+ "@marsidev/react-turnstile": "^1.4.1",
64
71
  "@types/node": "^25.0.3",
65
72
  "@types/react": "^19.2.7",
66
73
  "@types/react-dom": "^19.2.3",
package/schema/auth.sql CHANGED
@@ -27,6 +27,8 @@ CREATE TABLE IF NOT EXISTS users (
27
27
  image TEXT,
28
28
  createdAt TIMESTAMP NOT NULL,
29
29
  updatedAt TIMESTAMP NOT NULL
30
+ -- Optional: For Gmail duplicate prevention (see docs)
31
+ -- normalized_email TEXT
30
32
  );
31
33
 
32
34
  -- Sessions