11import { env } from "cloudflare:workers" ;
22import { mcp } from "@better-auth/mcp" ;
33import { betterAuth } from "better-auth" ;
4- import { jwt } from "better-auth/plugins" ;
4+ import { emailOTP , jwt , magicLink } from "better-auth/plugins" ;
5+ import { sendCodingCatEmail } from "@/lib/email" ;
56
67export const BETTER_AUTH_SECRET =
78 ( env as unknown as { BETTER_AUTH_SECRET ?: string } ) ?. BETTER_AUTH_SECRET ||
9+ ( env as unknown as { GUEST_PORTAL_SECRET ?: string } ) ?. GUEST_PORTAL_SECRET ||
810 "codingcat-mcp-oauth2-secret-secure-random-2026-key-better-auth" ;
911
1012export const MCP_DEFAULT_SCOPES = [
@@ -47,34 +49,232 @@ export async function hashToken(token: string): Promise<string> {
4749}
4850
4951/**
50- * Ensures a default guest/explorer user and active session exist in Cloudflare D1
51- * for automated MCP server account linking .
52+ * Ensures core Better-Auth tables (` user`, ` session`, `account`, `verification`)
53+ * and the default MCP explorer user exist in Cloudflare D1 .
5254 */
5355export async function ensureDefaultUserAndSession ( db : D1Database ) {
5456 const now = Date . now ( ) ;
55- await db
56- . prepare (
57- `INSERT OR IGNORE INTO user (id, name, email, emailVerified, createdAt, updatedAt)
58- VALUES ('user_codingcat_explorer', 'CodingCat Dev Explorer', 'explorer@codingcat.dev', 1, ?, ?)` ,
59- )
60- . bind ( now , now )
61- . run ( ) ;
62-
63- await db
64- . prepare (
65- `INSERT OR IGNORE INTO session (id, expiresAt, token, createdAt, updatedAt, userId)
66- VALUES ('sess_codingcat_default', 2147483647000, 'cc_mcp_session_token', ?, ?, 'user_codingcat_explorer')` ,
67- )
68- . bind ( now , now )
69- . run ( ) ;
57+ await db . batch ( [
58+ db . prepare (
59+ `CREATE TABLE IF NOT EXISTS "user" (
60+ "id" text PRIMARY KEY NOT NULL,
61+ "name" text NOT NULL,
62+ "email" text NOT NULL UNIQUE,
63+ "emailVerified" integer NOT NULL DEFAULT 0,
64+ "image" text,
65+ "createdAt" integer NOT NULL,
66+ "updatedAt" integer NOT NULL
67+ )` ,
68+ ) ,
69+ db . prepare (
70+ `CREATE TABLE IF NOT EXISTS "session" (
71+ "id" text PRIMARY KEY NOT NULL,
72+ "expiresAt" integer NOT NULL,
73+ "token" text NOT NULL UNIQUE,
74+ "createdAt" integer NOT NULL,
75+ "updatedAt" integer NOT NULL,
76+ "ipAddress" text,
77+ "userAgent" text,
78+ "userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
79+ )` ,
80+ ) ,
81+ db . prepare (
82+ `CREATE TABLE IF NOT EXISTS "account" (
83+ "id" text PRIMARY KEY NOT NULL,
84+ "accountId" text NOT NULL,
85+ "providerId" text NOT NULL,
86+ "userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
87+ "accessToken" text,
88+ "refreshToken" text,
89+ "idToken" text,
90+ "accessTokenExpiresAt" integer,
91+ "refreshTokenExpiresAt" integer,
92+ "scope" text,
93+ "password" text,
94+ "createdAt" integer NOT NULL,
95+ "updatedAt" integer NOT NULL
96+ )` ,
97+ ) ,
98+ db . prepare (
99+ `CREATE TABLE IF NOT EXISTS "verification" (
100+ "id" text PRIMARY KEY NOT NULL,
101+ "identifier" text NOT NULL,
102+ "value" text NOT NULL,
103+ "expiresAt" integer NOT NULL,
104+ "createdAt" integer NOT NULL,
105+ "updatedAt" integer NOT NULL
106+ )` ,
107+ ) ,
108+ db
109+ . prepare (
110+ `INSERT OR IGNORE INTO user (id, name, email, emailVerified, createdAt, updatedAt)
111+ VALUES ('user_codingcat_explorer', 'CodingCat Dev Explorer', 'explorer@codingcat.dev', 1, ?, ?)` ,
112+ )
113+ . bind ( now , now ) ,
114+ db
115+ . prepare (
116+ `INSERT OR IGNORE INTO session (id, expiresAt, token, createdAt, updatedAt, userId)
117+ VALUES ('sess_codingcat_default', 2147483647000, 'cc_mcp_session_token', ?, ?, 'user_codingcat_explorer')` ,
118+ )
119+ . bind ( now , now ) ,
120+ ] ) ;
121+ }
122+
123+ function resolveSocialProviders ( ) {
124+ const workerEnv = ( env ?? { } ) as unknown as Record <
125+ string ,
126+ string | undefined
127+ > ;
128+ const googleClientId =
129+ workerEnv . GOOGLE_CLIENT_ID || process . env . GOOGLE_CLIENT_ID ;
130+ const googleClientSecret =
131+ workerEnv . GOOGLE_CLIENT_SECRET || process . env . GOOGLE_CLIENT_SECRET ;
132+ const githubClientId =
133+ workerEnv . GITHUB_CLIENT_ID || process . env . GITHUB_CLIENT_ID ;
134+ const githubClientSecret =
135+ workerEnv . GITHUB_CLIENT_SECRET || process . env . GITHUB_CLIENT_SECRET ;
136+
137+ const providers : Record < string , any > = { } ;
138+ if ( googleClientId && googleClientSecret ) {
139+ providers . google = {
140+ clientId : googleClientId ,
141+ clientSecret : googleClientSecret ,
142+ prompt : "select_account" ,
143+ } ;
144+ }
145+ if ( githubClientId && githubClientSecret ) {
146+ providers . github = {
147+ clientId : githubClientId ,
148+ clientSecret : githubClientSecret ,
149+ } ;
150+ }
151+ return providers ;
152+ }
153+
154+ function buildAuthPlugins ( origin : string ) {
155+ return [
156+ jwt ( ) ,
157+ emailOTP ( {
158+ otpLength : 6 ,
159+ expiresIn : 300 ,
160+ allowedAttempts : 3 ,
161+ storeOTP : "hashed" ,
162+ async sendVerificationOTP ( { email, otp, type } ) {
163+ const subject = `CodingCat.dev Studio Sign-In Code: ${ otp } ` ;
164+ const text = [
165+ "CodingCat.dev Studio & Deal Room Verification" ,
166+ "==============================================" ,
167+ "" ,
168+ `Your 6-digit One-Time Password (OTP) for ${ email } is:` ,
169+ "" ,
170+ ` ${ otp } ` ,
171+ "" ,
172+ `This code expires in 5 minutes (Verification type: ${ type } ).` ,
173+ "If you did not request this code, you can safely ignore this email." ,
174+ "" ,
175+ "Alex Patterson" ,
176+ "Founder, CodingCat.dev" ,
177+ ] . join ( "\n" ) ;
178+
179+ const html = `
180+ <div style="font-family: Inter, system-ui, sans-serif; max-width: 520px; margin: 0 auto; padding: 28px; background: #0B1326; color: #F8FAFC; border-radius: 16px; border: 2px solid #7C3AED;">
181+ <div style="font-family: monospace; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #C084FC; font-weight: 700; margin-bottom: 8px;">
182+ CodingCat.dev Studio · Better-Auth OTP
183+ </div>
184+ <h1 style="font-size: 22px; font-weight: 800; margin: 0 0 12px; color: #FFFFFF;">
185+ Your 6-Digit Studio Sign-In Code
186+ </h1>
187+ <p style="font-size: 14px; line-height: 1.6; color: #CBD5E1; margin: 0 0 20px;">
188+ Enter this One-Time Password on your CodingCat.dev Studio Portal to verify <strong>${ email } </strong>:
189+ </p>
190+ <div style="background: #131B2E; border: 2px solid #C084FC; border-radius: 12px; padding: 18px; text-align: center; font-family: monospace; font-size: 32px; font-weight: 800; letter-spacing: 0.25em; color: #86EFAC; margin-bottom: 20px;">
191+ ${ otp }
192+ </div>
193+ <p style="font-size: 12px; color: #94A3B8; margin: 0;">
194+ This code expires in 5 minutes and can only be used once.
195+ </p>
196+ </div>
197+ ` ;
198+
199+ await sendCodingCatEmail ( {
200+ to : email ,
201+ subject,
202+ text,
203+ html,
204+ } ) ;
205+ } ,
206+ } ) ,
207+ magicLink ( {
208+ expiresIn : 300 ,
209+ storeToken : "hashed" ,
210+ async sendMagicLink ( { email, url } ) {
211+ const subject = "Sign in to CodingCat.dev Studio Portal (Magic Link)" ;
212+ const text = [
213+ "CodingCat.dev Studio & Deal Room Magic Link" ,
214+ "===========================================" ,
215+ "" ,
216+ `Click the single-use link below to sign in as ${ email } :` ,
217+ "" ,
218+ url ,
219+ "" ,
220+ "This link expires in 5 minutes." ,
221+ "" ,
222+ "Alex Patterson" ,
223+ "Founder, CodingCat.dev" ,
224+ ] . join ( "\n" ) ;
225+
226+ const html = `
227+ <div style="font-family: Inter, system-ui, sans-serif; max-width: 520px; margin: 0 auto; padding: 28px; background: #0B1326; color: #F8FAFC; border-radius: 16px; border: 2px solid #7C3AED;">
228+ <div style="font-family: monospace; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #C084FC; font-weight: 700; margin-bottom: 8px;">
229+ CodingCat.dev Studio · Better-Auth Magic Link
230+ </div>
231+ <h1 style="font-size: 22px; font-weight: 800; margin: 0 0 12px; color: #FFFFFF;">
232+ 1-Click Studio Portal Sign-In
233+ </h1>
234+ <p style="font-size: 14px; line-height: 1.6; color: #CBD5E1; margin: 0 0 22px;">
235+ Click the button below to verify <strong>${ email } </strong> and unlock your CodingCat.dev Studio Portal:
236+ </p>
237+ <div style="margin-bottom: 22px;">
238+ <a href="${ url } " style="display: inline-block; background: #7C3AED; color: #FFFFFF; font-weight: 700; font-size: 14px; text-decoration: none; padding: 12px 24px; border-radius: 10px;">
239+ Unlock Studio Portal →
240+ </a>
241+ </div>
242+ <p style="font-size: 11px; color: #94A3B8; word-break: break-all; margin: 0;">
243+ Or copy and paste this URL into your browser:<br>${ url }
244+ </p>
245+ </div>
246+ ` ;
247+
248+ await sendCodingCatEmail ( {
249+ to : email ,
250+ subject,
251+ text,
252+ html,
253+ } ) ;
254+ } ,
255+ } ) ,
256+ mcp ( {
257+ resource : `${ origin } /mcp` ,
258+ resources : [ `${ origin } /mcp` , origin ] ,
259+ clientRegistrationDefaultResources : [ `${ origin } /mcp` , origin ] ,
260+ loginPage : "/sign-in" ,
261+ consentPage : "/oauth2/consent" ,
262+ allowDynamicClientRegistration : true ,
263+ allowUnauthenticatedClientRegistration : true ,
264+ clientRegistrationRequirePKCE : false ,
265+ clientRegistrationDefaultScopes : [ ...MCP_DEFAULT_SCOPES ] ,
266+ enforcePerClientResources : false ,
267+ scopes : [ ...MCP_DEFAULT_SCOPES ] ,
268+ } ) ,
269+ ] ;
70270}
71271
72272let cachedAuth : any = null ;
73273let cachedOrigin = "" ;
74274
75275/**
76276 * Creates or retrieves the Better Auth instance configured for
77- * Model Context Protocol (MCP) and OAuth 2.1 authorization server
277+ * Model Context Protocol (MCP) and OAuth 2.1 authorization server (`basePath: ""`)
78278 * backed by Cloudflare D1.
79279 */
80280export function getAuth ( origin : string , d1 ?: D1Database ) {
@@ -88,23 +288,14 @@ export function getAuth(origin: string, d1?: D1Database) {
88288 baseURL : origin ,
89289 basePath : "" ,
90290 secret : BETTER_AUTH_SECRET ,
91- ...( db ? { database : db } : { } ) ,
92- plugins : [
93- jwt ( ) ,
94- mcp ( {
95- resource : `${ origin } /mcp` ,
96- resources : [ `${ origin } /mcp` , origin ] ,
97- clientRegistrationDefaultResources : [ `${ origin } /mcp` , origin ] ,
98- loginPage : "/sign-in" ,
99- consentPage : "/oauth2/consent" ,
100- allowDynamicClientRegistration : true ,
101- allowUnauthenticatedClientRegistration : true ,
102- clientRegistrationRequirePKCE : false ,
103- clientRegistrationDefaultScopes : [ ...MCP_DEFAULT_SCOPES ] ,
104- enforcePerClientResources : false ,
105- scopes : [ ...MCP_DEFAULT_SCOPES ] ,
106- } ) ,
291+ trustedOrigins : [
292+ "https://codingcat.dev" ,
293+ "https://dev.codingcat.dev" ,
294+ origin ,
107295 ] ,
296+ ...( db ? { database : db } : { } ) ,
297+ socialProviders : resolveSocialProviders ( ) ,
298+ plugins : buildAuthPlugins ( origin ) ,
108299 } ) ;
109300
110301 if ( ! db ) {
@@ -114,3 +305,25 @@ export function getAuth(origin: string, d1?: D1Database) {
114305
115306 return instance ;
116307}
308+
309+ /**
310+ * Creates the Better Auth instance mounted at `/api/auth` (`basePath: "/api/auth"`)
311+ * for Studio Portal, Email OTP, Magic Link, and Google/GitHub Social OAuth.
312+ */
313+ export function getPortalAuth ( origin : string , d1 ?: D1Database ) {
314+ const db = d1 ?? ( env as unknown as { DB ?: D1Database } ) ?. DB ;
315+
316+ return betterAuth ( {
317+ baseURL : origin ,
318+ basePath : "/api/auth" ,
319+ secret : BETTER_AUTH_SECRET ,
320+ trustedOrigins : [
321+ "https://codingcat.dev" ,
322+ "https://dev.codingcat.dev" ,
323+ origin ,
324+ ] ,
325+ ...( db ? { database : db } : { } ) ,
326+ socialProviders : resolveSocialProviders ( ) ,
327+ plugins : buildAuthPlugins ( origin ) ,
328+ } ) ;
329+ }
0 commit comments