Skip to content

Commit 1638f32

Browse files
committed
fix(cli): implement dynamic CORS origin handling and improve database connection logic
1 parent f933a38 commit 1638f32

12 files changed

Lines changed: 234 additions & 69 deletions

File tree

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@better-auth-cloudflare/cli",
3-
"version": "0.1.18",
3+
"version": "0.1.19",
44
"description": "CLI to generate Better Auth Cloudflare projects (Hono or OpenNext.js)",
55
"author": "Zach Grimaldi",
66
"repository": {

cli/src/index.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
extractKvNamespaceId,
1313
initializeGitRepository,
1414
parseWranglerToml,
15+
replaceDemoCorsOrigin,
1516
updateD1BlockWithId,
1617
updateHyperdriveBlockWithId,
1718
updateKvBlockWithId,
@@ -1502,7 +1503,7 @@ export const verification = {} as any;`;
15021503
);
15031504
}
15041505
if (answers.database === "hyperdrive-mysql") {
1505-
return code.replace(/dialect:\s*"sqlite"/g, 'dialect: "mysql2"').replace(
1506+
return code.replace(/dialect:\s*"sqlite"/g, 'dialect: "mysql"').replace(
15061507
/\.\.\.\(process\.env\.NODE_ENV === "production"[\s\S]*?\}\),/g,
15071508
`...(process.env.NODE_ENV === "production"
15081509
? {
@@ -1519,6 +1520,12 @@ export const verification = {} as any;`;
15191520
}
15201521
return code;
15211522
});
1523+
1524+
// Replace the demo CORS origin with a dynamic function that derives
1525+
// the allowed origin from the request URL (works in dev, prod, and custom domains)
1526+
const honoEntryPath = join(targetDir, "src/index.ts");
1527+
debugLog("Replacing demo CORS origin with dynamic origin function");
1528+
tryUpdateFile(honoEntryPath, replaceDemoCorsOrigin);
15221529
}
15231530
15241531
if (isNext) {
@@ -1584,7 +1591,7 @@ export const verification = {} as any;`;
15841591
);
15851592
}
15861593
if (answers.database === "hyperdrive-mysql") {
1587-
return code.replace(/dialect:\s*"sqlite"/g, 'dialect: "mysql2"').replace(
1594+
return code.replace(/dialect:\s*"sqlite"/g, 'dialect: "mysql"').replace(
15881595
/\.\.\.\(process\.env\.NODE_ENV === "production"[\s\S]*?\}\),/g,
15891596
`...(process.env.NODE_ENV === "production"
15901597
? {
@@ -1961,7 +1968,9 @@ export const verification = {} as any;`;
19611968
if (authRes.code === 0) {
19621969
genAuth.succeed("Auth schema updated.");
19631970

1964-
// Restore original schema.ts and index.ts after successful auth generation for non-D1 databases
1971+
// Regenerate schema.ts and db/index.ts after auth generation for non-D1 databases.
1972+
// The pre-auth-generation temp files used simplified versions to avoid circular deps;
1973+
// now we write back the correct generated content.
19651974
if (answers.database !== "d1") {
19661975
const tempSchemaBackupPath = join(targetDir, ".schema-backup.tmp");
19671976
const tempIndexBackupPath = join(targetDir, ".index-backup.tmp");
@@ -1975,12 +1984,23 @@ export const verification = {} as any;`;
19751984
rmSync(tempSchemaBackupPath, { force: true });
19761985
}
19771986

1987+
// Regenerate the correct db/index.ts instead of restoring the template default,
1988+
// since the final generated version (with correct DB driver) was already written
1989+
// before auth:update overwrote it with the temp version.
19781990
if (existsSync(tempIndexBackupPath)) {
1979-
debugLog(`Restoring original index file: ${indexPath}`);
1980-
const originalIndexContent = readFileSync(tempIndexBackupPath, "utf8");
1981-
writeFileSync(indexPath, originalIndexContent, "utf8");
19821991
rmSync(tempIndexBackupPath, { force: true });
19831992
}
1993+
const { generateDbIndex } = await import("./lib/db-generator");
1994+
const postAuthDbConfig = {
1995+
template: answers.template as "hono" | "nextjs",
1996+
database: answers.database === "hyperdrive-postgres" ? ("postgres" as const) : ("mysql" as const),
1997+
bindings: {
1998+
d1: answers.d1Binding,
1999+
hyperdrive: answers.hdBinding,
2000+
},
2001+
};
2002+
debugLog(`Regenerating db/index.ts with correct driver: ${indexPath}`);
2003+
writeFileSync(indexPath, generateDbIndex(postAuthDbConfig));
19842004
}
19852005
} else {
19862006
genAuth.fail("Failed to generate auth schema.");

cli/src/lib/auth-generator.ts

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ function generateHonoAuth(config: AuthConfig): string {
3939
imports.push(`import { drizzle } from "drizzle-orm/postgres-js";`);
4040
} else {
4141
imports.push(`import { drizzle } from "drizzle-orm/mysql2";`);
42+
imports.push(`import mysql from "mysql2";`);
4243
}
4344

4445
imports.push(`import { schema } from "../db";`, `import type { CloudflareBindings } from "../env";`);
@@ -205,47 +206,49 @@ function generateHonoCloudflareConfig(config: AuthConfig): string {
205206
if (config.resources.r2) {
206207
parts.push(`
207208
// R2 configuration for file storage (${config.bindings.r2 || "R2_BUCKET"} binding from wrangler.toml)
208-
r2: {
209-
bucket: env?.${config.bindings.r2 || "R2_BUCKET"},
210-
maxFileSize: 2 * 1024 * 1024, // 2MB
211-
allowedTypes: [".jpg", ".jpeg", ".png", ".gif"],
212-
additionalFields: {
213-
category: { type: "string", required: false },
214-
isPublic: { type: "boolean", required: false },
215-
description: { type: "string", required: false },
216-
},
217-
hooks: {
218-
upload: {
219-
before: async (file, ctx) => {
220-
// Only allow authenticated users to upload files
221-
if (ctx.session === null) {
222-
return null; // Blocks upload
223-
}
224-
225-
// Only allow paid users to upload files (for example)
226-
const isPaidUser = (userId: string) => true; // example
227-
if (isPaidUser(ctx.session.user.id) === false) {
228-
return null; // Blocks upload
229-
}
230-
231-
// Allow upload
232-
},
233-
after: async (file, ctx) => {
234-
// Track your analytics (for example)
235-
console.log("File uploaded:", file);
236-
},
209+
...(env?.${config.bindings.r2 || "R2_BUCKET"} ? {
210+
r2: {
211+
bucket: env.${config.bindings.r2 || "R2_BUCKET"},
212+
maxFileSize: 2 * 1024 * 1024, // 2MB
213+
allowedTypes: [".jpg", ".jpeg", ".png", ".gif"],
214+
additionalFields: {
215+
category: { type: "string", required: false },
216+
isPublic: { type: "boolean", required: false },
217+
description: { type: "string", required: false },
237218
},
238-
download: {
239-
before: async (file, ctx) => {
240-
// Only allow user to access their own files (by default all files are public)
241-
if (file.isPublic === false && file.userId !== ctx.session?.user.id) {
242-
return null; // Blocks download
243-
}
244-
// Allow download
219+
hooks: {
220+
upload: {
221+
before: async (file, ctx) => {
222+
// Only allow authenticated users to upload files
223+
if (ctx.session === null) {
224+
return null; // Blocks upload
225+
}
226+
227+
// Only allow paid users to upload files (for example)
228+
const isPaidUser = (userId: string) => true; // example
229+
if (isPaidUser(ctx.session.user.id) === false) {
230+
return null; // Blocks upload
231+
}
232+
233+
// Allow upload
234+
},
235+
after: async (file, ctx) => {
236+
// Track your analytics (for example)
237+
console.log("File uploaded:", file);
238+
},
239+
},
240+
download: {
241+
before: async (file, ctx) => {
242+
// Only allow user to access their own files (by default all files are public)
243+
if (file.isPublic === false && file.userId !== ctx.session?.user.id) {
244+
return null; // Blocks download
245+
}
246+
// Allow download
247+
},
245248
},
246249
},
247250
},
248-
},`);
251+
} : {}),`);
249252
}
250253

251254
return parts.join("");
@@ -351,12 +354,19 @@ const generateHonoSchemaConfig = generateSchemaConfig;
351354
const generateNextjsSchemaConfig = generateSchemaConfig;
352355

353356
function generateDbConnection(config: AuthConfig): string {
357+
const binding = config.bindings.hyperdrive || "HYPERDRIVE";
354358
if (config.database === "sqlite") {
355359
return `drizzle(env.${config.bindings.d1 || "DATABASE"}, { schema, logger: true })`;
356360
} else if (config.database === "postgres") {
357-
return `drizzle(env.${config.bindings.hyperdrive || "HYPERDRIVE"}, { schema, logger: true })`;
361+
return `drizzle(env.${binding}, { schema, logger: true })`;
358362
} else {
359-
return `drizzle(env.${config.bindings.hyperdrive || "HYPERDRIVE"}, { schema, logger: true })`;
363+
return `drizzle(mysql.createPool({
364+
host: env.${binding}.host,
365+
user: env.${binding}.user,
366+
password: env.${binding}.password,
367+
database: env.${binding}.database,
368+
port: env.${binding}.port,
369+
}), { schema, mode: "default", logger: true })`;
360370
}
361371
}
362372

cli/src/lib/helpers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ export interface JSONObject {
44
}
55
export interface JSONArray extends Array<JSONValue> {}
66

7+
const DYNAMIC_CORS_ORIGIN = `origin: (requestOrigin: string, c) => {
8+
const self = new URL(c.req.url).origin;
9+
return requestOrigin === self ? requestOrigin : "";
10+
},`;
11+
12+
/**
13+
* Replace a hardcoded/wildcard CORS origin in the Hono template with a dynamic
14+
* origin function that derives the allowed origin from the request URL.
15+
* Handles both the demo URL and the wildcard `"*"` placeholder.
16+
* Returns the input unchanged if neither pattern is present.
17+
*/
18+
export function replaceDemoCorsOrigin(code: string): string {
19+
return code
20+
.replace(`origin: "https://better-auth-cloudflare-hono.zpg6.workers.dev",`, DYNAMIC_CORS_ORIGIN)
21+
.replace(/origin: "\*",.*/, DYNAMIC_CORS_ORIGIN);
22+
}
23+
724
export function validateBindingName(name: string): string | undefined {
825
if (!name || name.trim().length === 0) return "Please enter a binding name";
926
if (!/^[A-Z0-9_]+$/.test(name)) return "Use ONLY A-Z, 0-9, and underscores";

cli/tests/auth-generator.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe("Auth Generator", () => {
8080

8181
const result = generateAuthFile(config);
8282

83-
expect(result).toContain("bucket: env?.MY_BUCKET");
83+
expect(result).toContain("bucket: env.MY_BUCKET");
8484
expect(result).toContain("maxFileSize: 2 * 1024 * 1024");
8585
expect(result).toContain('allowedTypes: [".jpg", ".jpeg", ".png", ".gif"]');
8686
expect(result).toContain("hooks: {");
@@ -98,7 +98,7 @@ describe("Auth Generator", () => {
9898

9999
expect(result).toContain("d1: env");
100100
expect(result).toContain("kv: env?.KV");
101-
expect(result).toContain("bucket: env?.R2_BUCKET");
101+
expect(result).toContain("bucket: env.R2_BUCKET");
102102
});
103103
});
104104

@@ -247,7 +247,7 @@ describe("Auth Generator", () => {
247247

248248
// Should use default binding names
249249
expect(result).toContain("kv: env?.KV");
250-
expect(result).toContain("bucket: env?.R2_BUCKET");
250+
expect(result).toContain("bucket: env.R2_BUCKET");
251251
});
252252

253253
test("handles empty resources", () => {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { readFileSync } from "fs";
3+
import { join } from "path";
4+
import { replaceDemoCorsOrigin } from "../src/lib/helpers";
5+
6+
const HONO_TEMPLATE_SRC = readFileSync(join(__dirname, "../../examples/hono/src/index.ts"), "utf8");
7+
8+
const DEMO_URL = "better-auth-cloudflare-hono.zpg6.workers.dev";
9+
10+
const WILDCARD_VARIANT = HONO_TEMPLATE_SRC.replace(
11+
`origin: "https://${DEMO_URL}",`,
12+
'origin: "*", // In production, replace with your actual domain'
13+
);
14+
15+
describe("replaceDemoCorsOrigin", () => {
16+
describe("demo URL variant", () => {
17+
test("removes the hardcoded demo URL", () => {
18+
const result = replaceDemoCorsOrigin(HONO_TEMPLATE_SRC);
19+
expect(result).not.toContain(DEMO_URL);
20+
});
21+
22+
test("inserts dynamic origin callback", () => {
23+
const result = replaceDemoCorsOrigin(HONO_TEMPLATE_SRC);
24+
expect(result).toContain("origin: (requestOrigin: string, c) =>");
25+
expect(result).toContain("new URL(c.req.url).origin");
26+
});
27+
28+
test("produces syntactically valid CORS block", () => {
29+
const result = replaceDemoCorsOrigin(HONO_TEMPLATE_SRC);
30+
const corsBlockMatch = result.match(/cors\(\{([\s\S]*?)\}\)/);
31+
expect(corsBlockMatch).not.toBeNull();
32+
const corsBody = corsBlockMatch![1];
33+
expect(corsBody).toContain("origin:");
34+
expect(corsBody).toContain("allowHeaders:");
35+
expect(corsBody).toContain("credentials:");
36+
});
37+
});
38+
39+
describe('wildcard variant (origin: "*")', () => {
40+
test("removes the wildcard origin", () => {
41+
const result = replaceDemoCorsOrigin(WILDCARD_VARIANT);
42+
expect(result).not.toContain('origin: "*"');
43+
});
44+
45+
test("inserts dynamic origin callback", () => {
46+
const result = replaceDemoCorsOrigin(WILDCARD_VARIANT);
47+
expect(result).toContain("origin: (requestOrigin: string, c) =>");
48+
expect(result).toContain("new URL(c.req.url).origin");
49+
});
50+
51+
test("produces syntactically valid CORS block", () => {
52+
const result = replaceDemoCorsOrigin(WILDCARD_VARIANT);
53+
const corsBlockMatch = result.match(/cors\(\{([\s\S]*?)\}\)/);
54+
expect(corsBlockMatch).not.toBeNull();
55+
const corsBody = corsBlockMatch![1];
56+
expect(corsBody).toContain("origin:");
57+
expect(corsBody).toContain("allowHeaders:");
58+
expect(corsBody).toContain("credentials:");
59+
});
60+
});
61+
62+
test("does NOT run on NextJS template content (no-op for unrelated files)", () => {
63+
const nextjsContent = `
64+
import { NextResponse } from "next/server";
65+
export async function middleware(request) {
66+
return NextResponse.next();
67+
}
68+
`;
69+
const result = replaceDemoCorsOrigin(nextjsContent);
70+
expect(result).toBe(nextjsContent);
71+
});
72+
73+
test("is idempotent — second call is a no-op", () => {
74+
const first = replaceDemoCorsOrigin(HONO_TEMPLATE_SRC);
75+
const second = replaceDemoCorsOrigin(first);
76+
expect(second).toBe(first);
77+
});
78+
79+
test("is idempotent for wildcard variant", () => {
80+
const first = replaceDemoCorsOrigin(WILDCARD_VARIANT);
81+
const second = replaceDemoCorsOrigin(first);
82+
expect(second).toBe(first);
83+
});
84+
85+
test("does not corrupt the file when neither pattern is present", () => {
86+
const alreadyEdited = HONO_TEMPLATE_SRC.replace(
87+
`origin: "https://${DEMO_URL}",`,
88+
'origin: "https://my-custom-domain.com",'
89+
);
90+
const result = replaceDemoCorsOrigin(alreadyEdited);
91+
expect(result).toBe(alreadyEdited);
92+
expect(result).toContain('origin: "https://my-custom-domain.com"');
93+
});
94+
});

cli/tests/integration/validators.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@ export class FileValidator {
350350
if (!index.includes("/api/auth/*")) {
351351
errors.push("Missing auth routes in Hono app");
352352
}
353+
if (index.includes('origin: "*"') || index.includes("better-auth-cloudflare-hono.zpg6.workers.dev")) {
354+
errors.push("CORS origin should be replaced with dynamic origin function");
355+
}
353356
} catch (error) {
354357
errors.push(`Failed to read index.ts: ${error}`);
355358
}

examples/opennextjs/src/app/dashboard/SignOutButton.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,9 @@ export default function SignOutButton() {
4848
},
4949
},
5050
});
51-
} catch (e: any) {
52-
// Catch any unexpected errors during the signOut call itself
51+
} catch (e) {
5352
console.error("Unexpected sign out error:", e);
54-
setError(e.message || "An unexpected error occurred. Please try again.");
53+
setError(e instanceof Error ? e.message : "An unexpected error occurred. Please try again.");
5554
// router.replace("/"); // Fallback redirect
5655
} finally {
5756
setIsLoading(false);

0 commit comments

Comments
 (0)