-
Notifications
You must be signed in to change notification settings - Fork 0
Fix CLI/template issues; harden auth, migrate/generate flows and realtime #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,7 +44,20 @@ const loginSchema = z.object({ | |
| }); | ||
|
|
||
| authRoute.post('/signup', async (c) => { | ||
| const body = signupSchema.parse(await c.req.json()); | ||
| let rawBody: unknown; | ||
| try { | ||
| rawBody = await c.req.json(); | ||
| } catch (err) { | ||
| const details = err instanceof Error ? err.message : String(err); | ||
| return c.json({ error: 'Invalid JSON', details }, 400); | ||
| } | ||
|
|
||
| const result = signupSchema.safeParse(rawBody); | ||
| if (!result.success) { | ||
| return c.json({ error: 'Invalid signup payload', details: result.error.format() }, 400); | ||
| } | ||
|
|
||
| const body = result.data; | ||
| const passwordHash = await Bun.password.hash(body.password); | ||
|
|
||
| const created = await db | ||
|
|
@@ -56,17 +69,35 @@ authRoute.post('/signup', async (c) => { | |
| }) | ||
| .returning(); | ||
|
|
||
| const createdUser = created[0]; | ||
| if (!createdUser || typeof createdUser !== 'object') { | ||
| return c.json({ error: 'Failed to create user record' }, 500); | ||
| } | ||
|
|
||
| return c.json({ | ||
| user: { | ||
| id: created[0].id, | ||
| email: created[0].email, | ||
| name: created[0].name, | ||
| id: createdUser.id, | ||
| email: createdUser.email, | ||
| name: createdUser.name, | ||
| }, | ||
| }, 201); | ||
| }); | ||
|
|
||
| authRoute.post('/login', async (c) => { | ||
| const body = loginSchema.parse(await c.req.json()); | ||
| let rawBody: unknown; | ||
| try { | ||
| rawBody = await c.req.json(); | ||
| } catch (err) { | ||
| const details = err instanceof Error ? err.message : String(err); | ||
| return c.json({ error: 'Invalid JSON', details }, 400); | ||
| } | ||
|
|
||
| const result = loginSchema.safeParse(rawBody); | ||
| if (!result.success) { | ||
| return c.json({ error: 'Invalid login payload', details: result.error.format() }, 400); | ||
| } | ||
|
|
||
| const body = result.data; | ||
|
|
||
| const user = await db.select().from(users).where(eq(users.email, body.email)).limit(1); | ||
| if (user.length === 0 || !user[0].passwordHash) { | ||
|
|
@@ -142,7 +173,16 @@ async function validateSession(token: string): Promise<AuthContext['user'] | nul | |
|
|
||
| if (session.length === 0) return null; | ||
|
|
||
| const user = await db.select().from(users).where(eq(users.id, session[0].userId)).limit(1); | ||
| const user = await db | ||
| .select({ | ||
| id: users.id, | ||
| email: users.email, | ||
| name: users.name, | ||
| }) | ||
| .from(users) | ||
| .where(eq(users.id, session[0].userId)) | ||
| .limit(1); | ||
|
|
||
| return user.length > 0 ? user[0] : null; | ||
| } | ||
|
|
||
|
|
@@ -196,14 +236,99 @@ function ensurePasswordHashColumn(schemaPath: string): void { | |
| return; | ||
| } | ||
|
|
||
| const usersBlock = current.match(/export\s+const\s+users\s*=\s*sqliteTable\([^]+?\}\);/m); | ||
| if (!usersBlock) { | ||
| const usersExportIdx = current.search(/export\s+const\s+users\s*=\s*sqliteTable\s*\(/); | ||
| if (usersExportIdx === -1) { | ||
| logger.warn('Could not find sqlite users table block; skipping passwordHash injection.'); | ||
| return; | ||
| } | ||
|
|
||
| const replacement = usersBlock[0].replace(/\n\}\);$/, "\n passwordHash: text('password_hash').notNull(),\n});"); | ||
| writeFileSync(schemaPath, current.replace(usersBlock[0], replacement)); | ||
| const callStart = current.indexOf('sqliteTable(', usersExportIdx); | ||
| if (callStart === -1) { | ||
| logger.warn('Could not locate sqliteTable call for users; skipping passwordHash injection.'); | ||
| return; | ||
| } | ||
|
|
||
| let i = callStart; | ||
| let parenDepth = 0; | ||
| let inSingle = false; | ||
| let inDouble = false; | ||
| let inBacktick = false; | ||
| let escaped = false; | ||
|
|
||
| while (i < current.length) { | ||
| const ch = current[i]; | ||
| const next = current[i + 1]; | ||
|
|
||
| if (escaped) { | ||
| escaped = false; | ||
| i += 1; | ||
| continue; | ||
| } | ||
|
|
||
| if ((inSingle || inDouble || inBacktick) && ch === '\\') { | ||
| escaped = true; | ||
| i += 1; | ||
| continue; | ||
| } | ||
|
|
||
| if (!inDouble && !inBacktick && ch === "'") { | ||
| if (inSingle && next === "'") { | ||
| i += 2; | ||
| continue; | ||
| } | ||
| inSingle = !inSingle; | ||
| i += 1; | ||
| continue; | ||
| } | ||
|
|
||
| if (!inSingle && !inBacktick && ch === '"') { | ||
| inDouble = !inDouble; | ||
| i += 1; | ||
| continue; | ||
| } | ||
|
|
||
| if (!inSingle && !inDouble && ch === '`') { | ||
| inBacktick = !inBacktick; | ||
| i += 1; | ||
| continue; | ||
| } | ||
|
|
||
| if (inSingle || inDouble || inBacktick) { | ||
| i += 1; | ||
| continue; | ||
| } | ||
|
|
||
| if (ch === '(') { | ||
| parenDepth += 1; | ||
| } else if (ch === ')') { | ||
| parenDepth -= 1; | ||
| if (parenDepth === 0) { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| i += 1; | ||
| } | ||
|
|
||
| if (i >= current.length || parenDepth !== 0) { | ||
| logger.warn('Could not safely parse users sqliteTable block; skipping passwordHash injection.'); | ||
| return; | ||
| } | ||
|
|
||
| const statementEnd = current.indexOf(';', i); | ||
| if (statementEnd === -1) { | ||
| logger.warn('Could not locate end of users sqliteTable statement; skipping passwordHash injection.'); | ||
| return; | ||
| } | ||
|
|
||
| const usersBlock = current.slice(usersExportIdx, statementEnd + 1); | ||
| const replacement = usersBlock.replace(/\n\}\);\s*$/, "\n passwordHash: text('password_hash').notNull(),\n});"); | ||
| if (replacement === usersBlock) { | ||
| logger.warn('Could not inject passwordHash into users table; block layout was unexpected.'); | ||
| return; | ||
| } | ||
|
|
||
| writeFileSync(schemaPath, `${current.slice(0, usersExportIdx)}${replacement}${current.slice(statementEnd + 1)}`); | ||
|
Comment on lines
+239
to
+331
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's find the auth.ts file and examine the context
find . -name "auth.ts" -path "*/cli/src/commands/*" 2>/dev/null | head -5Repository: Helal-maker/Betterbase Length of output: 112 🏁 Script executed: # Search for sqliteTable calls in the codebase to see actual usage patterns
rg "sqliteTable\s*\(" -A 3 -B 1 --max-count=20Repository: Helal-maker/Betterbase Length of output: 5858 🏁 Script executed: # More specifically, search for 3-argument sqliteTable patterns (with callbacks)
rg "sqliteTable\s*\(\s*['\"]" -A 5 | head -50Repository: Helal-maker/Betterbase Length of output: 3631 🏁 Script executed: # Read the exact lines 239-331 in auth.ts
sed -n '239,331p' betterbase/packages/cli/src/commands/auth.tsRepository: Helal-maker/Betterbase Length of output: 2444 🏁 Script executed: # Also search for all sqliteTable calls with 3 arguments to confirm they exist
rg "sqliteTable\s*\(['\"].*?['\"],\s*\{[^}]*\},\s*\(" -A 3 --max-count=10Repository: Helal-maker/Betterbase Length of output: 48 🏁 Script executed: # Let me verify the exact pattern when a 3-arg sqliteTable call is used
cat -n betterbase/packages/cli/test/scanner.test.ts | sed -n '20,35p'Repository: Helal-maker/Betterbase Length of output: 822 🏁 Script executed: # Extract a more complete view of the 3-arg sqliteTable usage in the test
rg -A 10 "export const users = sqliteTable\('users'" betterbase/packages/cli/test/scanner.test.ts | head -15Repository: Helal-maker/Betterbase Length of output: 539 🏁 Script executed: # Test the proposed regex patterns to see if they correctly detect callbacks
python3 << 'EOF'
import re
# Sample 3-arg sqliteTable code
code_3arg = """}), (table) => ({
usersEmailIdx: index('users_email_idx').on(table.email),
}));"""
code_2arg = """}
);"""
# Test the proposed patterns
pattern1 = r'/\},\s*\(\s*\w+\s*\)\s*=>/'
pattern1_fixed = r'\},\s*\(\s*\w+\s*\)\s*=>'
pattern2_fixed = r'\},\s*function\s*\('
print("Testing with 3-arg callback:")
print(f"Pattern 1 match: {bool(re.search(pattern1_fixed, code_3arg))}")
print(f"Pattern 2 match: {bool(re.search(pattern2_fixed, code_3arg))}")
print("\nTesting with 2-arg (no callback):")
print(f"Pattern 1 match: {bool(re.search(pattern1_fixed, code_2arg))}")
print(f"Pattern 2 match: {bool(re.search(pattern2_fixed, code_2arg))}")
# Test the replacement pattern
replacement_pattern = r'\n\}\);\s*$'
test_block = "export const users = sqliteTable('users', {\n id: text('id'),\n});"
print(f"\nReplacement pattern matches 2-arg: {bool(re.search(replacement_pattern, test_block))}")
test_block_3arg = "export const users = sqliteTable('users', {\n id: text('id'),\n}), (table) => ({\n idx: index('idx')\n}));"
print(f"Replacement pattern matches 3-arg: {bool(re.search(replacement_pattern, test_block_3arg))}")
if re.search(replacement_pattern, test_block_3arg):
print(f" Would match at: {repr(re.search(replacement_pattern, test_block_3arg).group())}")
EOFRepository: Helal-maker/Betterbase Length of output: 303 🏁 Script executed: # Test corrected patterns for the actual 3-arg sqliteTable format
python3 << 'EOF'
import re
# Real 3-arg sqliteTable code from the test file
code_3arg = """ }, (table) => ({
usersEmailIdx: index('users_email_idx').on(table.email),
}));"""
code_2arg = """ }
);"""
# The actual pattern in the code: }, (table) => ({
pattern_correct = r'\}\),\s*\(\s*\w+\s*\)\s*=>'
print("Testing with actual 3-arg callback pattern:")
print(f"Corrected pattern match: {bool(re.search(pattern_correct, code_3arg))}")
print(f"Match found: {re.search(pattern_correct, code_3arg).group() if re.search(pattern_correct, code_3arg) else 'None'}")
print("\nTesting with 2-arg (no callback):")
print(f"Corrected pattern match: {bool(re.search(pattern_correct, code_2arg))}")
# Test the replacement patterns
print("\n--- Testing replacement patterns ---")
block_2arg = """export const users = sqliteTable('users', {
id: text('id'),
});"""
block_3arg = """export const users = sqliteTable('users', {
id: text('id'),
}), (table) => ({
idx: index('idx')
}));"""
# The code uses this pattern:
replacement_pattern = r'\n\}\);\s*$'
print(f"\n2-arg block ends with: {repr(block_2arg[-15:])}")
print(f"Pattern matches 2-arg: {bool(re.search(replacement_pattern, block_2arg))}")
print(f"\n3-arg block ends with: {repr(block_3arg[-15:])}")
print(f"Pattern matches 3-arg: {bool(re.search(replacement_pattern, block_3arg))}")
# What would happen if we try to inject into 3-arg?
if re.search(replacement_pattern, block_2arg):
result_2arg = re.sub(replacement_pattern, "\n passwordHash: text('password_hash').notNull(),\n});", block_2arg)
print(f"\n2-arg injection result:\n{result_2arg}")
if re.search(replacement_pattern, block_3arg):
result_3arg = re.sub(replacement_pattern, "\n passwordHash: text('password_hash').notNull(),\n});", block_3arg)
print(f"\n3-arg injection result:\n{result_3arg}")
else:
print(f"\n3-arg block: Pattern does NOT match (good - injection won't happen incorrectly)")
EOFRepository: Helal-maker/Betterbase Length of output: 626 🏁 Script executed: # Let's verify exactly what happens with the actual usersBlock when we have a 3-arg call
python3 << 'EOF'
import re
# Simulate what the code extracts as usersBlock for a 3-arg call
usersBlock_3arg = """export const users = sqliteTable('users', {
id: text('id'),
email: text('email').notNull().unique(),
age: integer('age').default(18),
}, (table) => ({
usersEmailIdx: index('users_email_idx').on(table.email),
}));"""
usersBlock_2arg = """export const users = sqliteTable('users', {
id: text('id'),
email: text('email').notNull().unique(),
age: integer('age').default(18),
});"""
# The actual replacement pattern from the code
replacement_pattern = r'\n\}\);\s*$'
replacement_text = "\n passwordHash: text('password_hash').notNull(),\n});"
# Test with 2-arg
result_2arg = usersBlock_2arg.replace(replacement_pattern, replacement_text)
print("2-arg replacement:")
print(f"Changed: {result_2arg != usersBlock_2arg}")
print(f"Result ends with: {repr(result_2arg[-50:])}\n")
# Test with 3-arg
result_3arg = usersBlock_3arg.replace(replacement_pattern, replacement_text)
print("3-arg replacement:")
print(f"Changed: {result_3arg != usersBlock_3arg}")
print(f"Original ends with: {repr(usersBlock_3arg[-50:])}")
if result_3arg == usersBlock_3arg:
print("⚠️ REPLACEMENT FAILED - would trigger the warning and return early")
else:
print(f"Result ends with: {repr(result_3arg[-50:])}")
# Now test the proposed detection patterns from the review
print("\n--- Testing proposed detection patterns ---")
pattern1 = r'\},\s*\(\s*\w+\s*\)\s*=>'
pattern2 = r'\},\s*function\s*\('
print(f"Pattern 1 (arrow fn) matches 3-arg: {bool(re.search(pattern1, usersBlock_3arg))}")
print(f"Pattern 2 (function) matches 3-arg: {bool(re.search(pattern2, usersBlock_3arg))}")
EOFRepository: Helal-maker/Betterbase Length of output: 477 Provide clearer diagnostics for unsupported 3-arg sqliteTable callbacks. The code already prevents schema corruption when encountering 3-arg |
||
| } | ||
|
|
||
| function ensureAuthInConfig(projectRoot: string): void { | ||
|
|
@@ -253,17 +378,31 @@ function ensureRoutesIndexHook(projectRoot: string): void { | |
| const routesIndexPath = path.join(projectRoot, 'src/routes/index.ts'); | ||
| if (!existsSync(routesIndexPath)) return; | ||
|
|
||
| let current = readFileSync(routesIndexPath, 'utf-8'); | ||
| const current = readFileSync(routesIndexPath, 'utf-8'); | ||
| const importAnchor = "import { usersRoute } from './users';"; | ||
| const routeAnchor = "app.route('/api/users', usersRoute);"; | ||
|
|
||
| let next = current; | ||
|
|
||
| if (!current.includes("import { authRoute } from './auth';")) { | ||
| current = current.replace("import { usersRoute } from './users';", "import { usersRoute } from './users';\nimport { authRoute } from './auth';"); | ||
| if (!next.includes("import { authRoute } from './auth';")) { | ||
| if (next.includes(importAnchor)) { | ||
| next = next.replace(importAnchor, `${importAnchor}\nimport { authRoute } from './auth';`); | ||
| } else { | ||
| logger.warn(`Could not find import anchor in ${routesIndexPath}; skipping auth route import injection.`); | ||
| } | ||
| } | ||
|
|
||
| if (!current.includes("app.route('/auth', authRoute);")) { | ||
| current = current.replace("app.route('/api/users', usersRoute);", "app.route('/api/users', usersRoute);\n app.route('/auth', authRoute);"); | ||
| if (!next.includes("app.route('/auth', authRoute);")) { | ||
| if (next.includes(routeAnchor)) { | ||
| next = next.replace(routeAnchor, `${routeAnchor}\n app.route('/auth', authRoute);`); | ||
| } else { | ||
| logger.warn(`Could not find route anchor in ${routesIndexPath}; skipping auth route registration injection.`); | ||
| } | ||
| } | ||
|
|
||
| writeFileSync(routesIndexPath, current); | ||
| if (next !== current) { | ||
| writeFileSync(routesIndexPath, next); | ||
| } | ||
| } | ||
|
|
||
| export async function runAuthSetupCommand(projectRoot: string = process.cwd()): Promise<void> { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,19 +72,35 @@ ${updateShape} | |
| }); | ||
|
|
||
| ${tableName}Route.get('/', async (c) => { | ||
| const limit = Number(c.req.query('limit') ?? 50); | ||
| const offset = Number(c.req.query('offset') ?? 0); | ||
| const safeLimit = Number.isFinite(limit) && limit >= 0 ? Math.min(limit, 100) : 50; | ||
| const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0; | ||
| const DEFAULT_LIMIT = 50; | ||
| const MAX_LIMIT = 100; | ||
| const DEFAULT_OFFSET = 0; | ||
|
|
||
| const paginationSchema = z.object({ | ||
| limit: z.coerce.number().int().nonnegative().default(DEFAULT_LIMIT), | ||
| offset: z.coerce.number().int().nonnegative().default(DEFAULT_OFFSET), | ||
| }); | ||
|
|
||
| const queryParams = c.req.query(); | ||
| const sort = queryParams.sort; | ||
| const paginationResult = paginationSchema.safeParse({ | ||
| limit: queryParams.limit, | ||
| offset: queryParams.offset, | ||
| }); | ||
|
|
||
| if (!paginationResult.success) { | ||
| return c.json({ error: 'Invalid pagination params', details: paginationResult.error.format() }, 400); | ||
| } | ||
|
|
||
| const { limit, offset } = paginationResult.data; | ||
| const fetchLimit = Math.min(limit, MAX_LIMIT); | ||
| const sort = queryParams.sort; | ||
| const filters = Object.entries(queryParams).filter(([key, value]) => key !== 'limit' && key !== 'offset' && key !== 'sort' && value !== undefined); | ||
|
|
||
| let query = db.select().from(${tableName}).$dynamic(); | ||
|
|
||
| if (filters.length > 0) { | ||
| // Security note: by default all table columns are filterable. Consider adding a schema scanner | ||
| // annotation (e.g., "filterable") and replacing this with an explicit allowlist for sensitive tables. | ||
| const conditions = filters | ||
| .filter(([key]) => key in ${tableName}) | ||
| .map(([key, value]) => eq(${tableName}[key as keyof typeof ${tableName}] as never, value as never)); | ||
|
|
@@ -102,8 +118,13 @@ ${tableName}Route.get('/', async (c) => { | |
| } | ||
| } | ||
|
|
||
| const items = await query.limit(safeLimit).offset(safeOffset); | ||
| return c.json({ ${tableName}: items, count: items.length, pagination: { limit: safeLimit, offset: safeOffset } }); | ||
| const items = await query.limit(fetchLimit + 1).offset(offset); | ||
| const hasMore = items.length > fetchLimit; | ||
|
|
||
| return c.json({ | ||
| ${tableName}: items.slice(0, fetchLimit), | ||
| pagination: { limit: fetchLimit, offset, hasMore }, | ||
| }); | ||
| }); | ||
|
|
||
| ${tableName}Route.get('/:id', async (c) => { | ||
|
|
@@ -185,7 +206,7 @@ function ensureRealtimeUtility(projectRoot: string): void { | |
| const realtimePath = path.join(projectRoot, 'src/lib/realtime.ts'); | ||
| if (existsSync(realtimePath)) return; | ||
|
|
||
| const canonicalRealtimePath = path.resolve(import.meta.dir, '../../../templates/base/src/lib/realtime.ts'); | ||
| const canonicalRealtimePath = path.resolve(import.meta.dir, '../../../../templates/base/src/lib/realtime.ts'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| if (!existsSync(canonicalRealtimePath)) { | ||
| throw new Error(`Canonical realtime template not found at ${canonicalRealtimePath}`); | ||
| } | ||
|
|
@@ -195,6 +216,28 @@ function ensureRealtimeUtility(projectRoot: string): void { | |
| } | ||
|
|
||
| async function ensureZodValidatorInstalled(projectRoot: string): Promise<void> { | ||
| const packageJsonPath = path.join(projectRoot, 'package.json'); | ||
| const modulePath = path.join(projectRoot, 'node_modules', '@hono', 'zod-validator'); | ||
|
|
||
| if (existsSync(modulePath)) { | ||
| return; | ||
| } | ||
|
|
||
| if (existsSync(packageJsonPath)) { | ||
| try { | ||
| const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { | ||
| dependencies?: Record<string, string>; | ||
| devDependencies?: Record<string, string>; | ||
| }; | ||
|
|
||
| if (packageJson.dependencies?.['@hono/zod-validator'] || packageJson.devDependencies?.['@hono/zod-validator']) { | ||
| return; | ||
| } | ||
| } catch { | ||
| // Fall through to install branch. | ||
| } | ||
| } | ||
|
|
||
| logger.info('Installing @hono/zod-validator...'); | ||
| const process = Bun.spawn(['bun', 'add', '@hono/zod-validator'], { | ||
| cwd: projectRoot, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.