diff --git a/scripts/test-summary.ts b/scripts/test-summary.ts index 0888c99..34a3324 100644 --- a/scripts/test-summary.ts +++ b/scripts/test-summary.ts @@ -1,58 +1,58 @@ -#!/usr/bin/env bun - -const proc = Bun.spawn(["bunx", "turbo", "run", "test"]); - -await proc.exited; - -const output = await new Response(proc.stdout).text(); -const errorOutput = await new Response(proc.stderr).text(); - -process.stdout.write(output); -process.stderr.write(errorOutput); - -const fullOutput = output + errorOutput; - -console.log(""); -console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); -console.log("📋 TEST SUMMARY"); -console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - -const passMatch = fullOutput.match(/(\d+) pass/g); -const failMatch = fullOutput.match(/(\d+) fail/g); -const testsMatch = fullOutput.match(/Ran (\d+) tests?/g); - -let totalPass = 0; -let totalFail = 0; -let totalTests = 0; - -if (passMatch) { - passMatch.forEach((m) => { - totalPass += Number.parseInt(m.split(" ")[0], 10); - }); -} - -if (failMatch) { - failMatch.forEach((m) => { - totalFail += Number.parseInt(m.split(" ")[0], 10); - }); -} - -if (testsMatch) { - testsMatch.forEach((m) => { - const num = m.match(/\d+/); - if (num) totalTests += Number.parseInt(num[0], 10); - }); -} - -if (totalTests > 0) { - console.log(`✅ Passed: ${totalPass} | ❌ Failed: ${totalFail} | 📝 Total Tests: ${totalTests}`); -} else if (totalPass > 0 || totalFail > 0) { - console.log(`✅ Passed: ${totalPass} | ❌ Failed: ${totalFail}`); -} else { - console.log("Run tests to see summary"); -} - -if (totalFail > 0 || proc.exitCode !== 0) { - process.exit(1); -} -console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +#!/usr/bin/env bun + +const proc = Bun.spawn(["bunx", "turbo", "run", "test"]); + +await proc.exited; + +const output = await new Response(proc.stdout).text(); +const errorOutput = await new Response(proc.stderr).text(); + +process.stdout.write(output); +process.stderr.write(errorOutput); + +const fullOutput = output + errorOutput; + +console.log(""); +console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +console.log("📋 TEST SUMMARY"); +console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + +const passMatch = fullOutput.match(/(\d+) pass/g); +const failMatch = fullOutput.match(/(\d+) fail/g); +const testsMatch = fullOutput.match(/Ran (\d+) tests?/g); + +let totalPass = 0; +let totalFail = 0; +let totalTests = 0; + +if (passMatch) { + passMatch.forEach((m) => { + totalPass += Number.parseInt(m.split(" ")[0], 10); + }); +} + +if (failMatch) { + failMatch.forEach((m) => { + totalFail += Number.parseInt(m.split(" ")[0], 10); + }); +} + +if (testsMatch) { + testsMatch.forEach((m) => { + const num = m.match(/\d+/); + if (num) totalTests += Number.parseInt(num[0], 10); + }); +} + +if (totalTests > 0) { + console.log(`✅ Passed: ${totalPass} | ❌ Failed: ${totalFail} | 📝 Total Tests: ${totalTests}`); +} else if (totalPass > 0 || totalFail > 0) { + console.log(`✅ Passed: ${totalPass} | ❌ Failed: ${totalFail}`); +} else { + console.log("Run tests to see summary"); +} + +if (totalFail > 0 || proc.exitCode !== 0) { + process.exit(1); +} +console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); diff --git a/templates/auth/src/auth/index.ts b/templates/auth/src/auth/index.ts index ceb3679..c397431 100644 --- a/templates/auth/src/auth/index.ts +++ b/templates/auth/src/auth/index.ts @@ -1,26 +1,26 @@ -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { db } from "../db"; -import * as schema from "../db/schema"; - -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "sqlite", - schema: { - user: schema.user, - session: schema.session, - account: schema.account, - verification: schema.verification, - }, - }), - emailAndPassword: { - enabled: true, - requireEmailVerification: false, - }, - secret: process.env.AUTH_SECRET, - baseURL: process.env.AUTH_URL ?? "http://localhost:3000", - trustedOrigins: [process.env.AUTH_URL ?? "http://localhost:3000"], - plugins: [], -}); - -export type Auth = typeof auth; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { db } from "../db"; +import * as schema from "../db/schema"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "sqlite", + schema: { + user: schema.user, + session: schema.session, + account: schema.account, + verification: schema.verification, + }, + }), + emailAndPassword: { + enabled: true, + requireEmailVerification: false, + }, + secret: process.env.AUTH_SECRET, + baseURL: process.env.AUTH_URL ?? "http://localhost:3000", + trustedOrigins: [process.env.AUTH_URL ?? "http://localhost:3000"], + plugins: [], +}); + +export type Auth = typeof auth; diff --git a/templates/auth/src/auth/types.ts b/templates/auth/src/auth/types.ts index 2a33da9..0ae559c 100644 --- a/templates/auth/src/auth/types.ts +++ b/templates/auth/src/auth/types.ts @@ -1,9 +1,9 @@ -import type { auth } from "./index"; - -export type Session = typeof auth.$Infer.Session.session; -export type User = typeof auth.$Infer.Session.user; - -export type AuthVariables = { - user: User; - session: Session; -}; +import type { auth } from "./index"; + +export type Session = typeof auth.$Infer.Session.session; +export type User = typeof auth.$Infer.Session.user; + +export type AuthVariables = { + user: User; + session: Session; +}; diff --git a/templates/auth/src/db/auth-schema.ts b/templates/auth/src/db/auth-schema.ts index 1b86110..3035a90 100644 --- a/templates/auth/src/db/auth-schema.ts +++ b/templates/auth/src/db/auth-schema.ts @@ -1,31 +1,31 @@ -import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; -import { users } from "./schema"; - -// Auth tables (generated by BetterAuth) -export const sessions = sqliteTable("sessions", { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => users.id), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), -}); - -export const accounts = sqliteTable( - "accounts", - { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => users.id), - provider: text("provider").notNull(), - providerAccountId: text("provider_account_id").notNull(), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - expiresAt: integer("expires_at", { mode: "timestamp" }), - }, - (t) => ({ - providerUnique: uniqueIndex("provider_unique").on(t.provider, t.providerAccountId), - }), -); +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { users } from "./schema"; + +// Auth tables (generated by BetterAuth) +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), +}); + +export const accounts = sqliteTable( + "accounts", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + provider: text("provider").notNull(), + providerAccountId: text("provider_account_id").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + expiresAt: integer("expires_at", { mode: "timestamp" }), + }, + (t) => ({ + providerUnique: uniqueIndex("provider_unique").on(t.provider, t.providerAccountId), + }), +); diff --git a/templates/auth/src/db/index.ts b/templates/auth/src/db/index.ts index 90e1c4e..18818e8 100644 --- a/templates/auth/src/db/index.ts +++ b/templates/auth/src/db/index.ts @@ -1,23 +1,23 @@ -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { accounts, sessions } from "./auth-schema"; -import * as schema from "./schema"; - -// Merge all schemas for Drizzle -const fullSchema = { - ...schema, - sessions, - accounts, -}; - -// Note: In a real app, you'd import env from '../lib/env' -// For the auth template, we use a default path -const DB_PATH = process.env.DB_PATH || "./data/auth.db"; - -const sqlite = new Database(DB_PATH, { create: true }); - -export const db = drizzle(sqlite, { schema: fullSchema }); - -// Re-export all schema tables -export { users } from "./schema"; -export { sessions, accounts }; +import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { accounts, sessions } from "./auth-schema"; +import * as schema from "./schema"; + +// Merge all schemas for Drizzle +const fullSchema = { + ...schema, + sessions, + accounts, +}; + +// Note: In a real app, you'd import env from '../lib/env' +// For the auth template, we use a default path +const DB_PATH = process.env.DB_PATH || "./data/auth.db"; + +const sqlite = new Database(DB_PATH, { create: true }); + +export const db = drizzle(sqlite, { schema: fullSchema }); + +// Re-export all schema tables +export { users } from "./schema"; +export { sessions, accounts }; diff --git a/templates/auth/src/db/schema.ts b/templates/auth/src/db/schema.ts index 161053e..07e283a 100644 --- a/templates/auth/src/db/schema.ts +++ b/templates/auth/src/db/schema.ts @@ -1,55 +1,55 @@ -import { sqliteTable, text } from "drizzle-orm/sqlite-core"; - -/** - * UUID primary-key helper. - */ -export const uuid = (name = "id") => - text(name) - .primaryKey() - .$defaultFn(() => crypto.randomUUID()); - -/** - * Adds created_at and updated_at timestamp columns. - * created_at is set on insert and updated_at is refreshed on updates. - * Note: .$onUpdate(() => new Date()) applies when updates go through Drizzle. - * Raw SQL writes will not auto-update this value without a DB trigger. - * - * @example - * export const users = sqliteTable('users', { - * id: uuid(), - * email: text('email'), - * ...timestamps, - * }); - */ -export const timestamps = { - createdAt: text("created_at") - .notNull() - .$defaultFn(() => new Date().toISOString()), - updatedAt: text("updated_at") - .notNull() - .$defaultFn(() => new Date().toISOString()) - .$onUpdate(() => new Date().toISOString()), -}; - -/** - * Shared status enum helper. - */ -export const statusEnum = (name = "status") => - text(name, { enum: ["active", "inactive", "pending"] }).default("active"); - -/** - * Soft-delete helper. - */ -export const softDelete = { - deletedAt: text("deleted_at"), -}; - -export const users = sqliteTable("user", { - id: uuid(), - email: text("email").notNull().unique(), - emailVerified: text("email_verified"), - name: text("name"), - status: statusEnum(), - ...timestamps, - ...softDelete, -}); +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; + +/** + * UUID primary-key helper. + */ +export const uuid = (name = "id") => + text(name) + .primaryKey() + .$defaultFn(() => crypto.randomUUID()); + +/** + * Adds created_at and updated_at timestamp columns. + * created_at is set on insert and updated_at is refreshed on updates. + * Note: .$onUpdate(() => new Date()) applies when updates go through Drizzle. + * Raw SQL writes will not auto-update this value without a DB trigger. + * + * @example + * export const users = sqliteTable('users', { + * id: uuid(), + * email: text('email'), + * ...timestamps, + * }); + */ +export const timestamps = { + createdAt: text("created_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: text("updated_at") + .notNull() + .$defaultFn(() => new Date().toISOString()) + .$onUpdate(() => new Date().toISOString()), +}; + +/** + * Shared status enum helper. + */ +export const statusEnum = (name = "status") => + text(name, { enum: ["active", "inactive", "pending"] }).default("active"); + +/** + * Soft-delete helper. + */ +export const softDelete = { + deletedAt: text("deleted_at"), +}; + +export const users = sqliteTable("user", { + id: uuid(), + email: text("email").notNull().unique(), + emailVerified: text("email_verified"), + name: text("name"), + status: statusEnum(), + ...timestamps, + ...softDelete, +}); diff --git a/templates/auth/src/middleware/auth.ts b/templates/auth/src/middleware/auth.ts index 2584e88..08951cd 100644 --- a/templates/auth/src/middleware/auth.ts +++ b/templates/auth/src/middleware/auth.ts @@ -1,25 +1,25 @@ -import type { Context, Next } from "hono"; -import { auth } from "../auth"; - -export async function requireAuth(c: Context, next: Next) { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }); - if (!session) { - return c.json({ data: null, error: "Unauthorized" }, 401); - } - c.set("user", session.user); - c.set("session", session.session); - await next(); -} - -export async function optionalAuth(c: Context, next: Next) { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }); - if (session) { - c.set("user", session.user); - c.set("session", session.session); - } - await next(); -} +import type { Context, Next } from "hono"; +import { auth } from "../auth"; + +export async function requireAuth(c: Context, next: Next) { + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + if (!session) { + return c.json({ data: null, error: "Unauthorized" }, 401); + } + c.set("user", session.user); + c.set("session", session.session); + await next(); +} + +export async function optionalAuth(c: Context, next: Next) { + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + if (session) { + c.set("user", session.user); + c.set("session", session.session); + } + await next(); +} diff --git a/templates/auth/src/routes/auth-example.ts b/templates/auth/src/routes/auth-example.ts index 103d1f0..50d5190 100644 --- a/templates/auth/src/routes/auth-example.ts +++ b/templates/auth/src/routes/auth-example.ts @@ -1,46 +1,46 @@ -import { Hono } from "hono"; -import { requireAuth } from "../middleware/auth"; - -const authExampleRoute = new Hono(); - -// Example: Get current user (protected route) -authExampleRoute.get("/me", requireAuth, async (c) => { - const user = c.get("user"); - const session = c.get("session"); - - return c.json({ - user: { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified, - image: user.image, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }, - session: { - id: session.id, - expiresAt: session.expiresAt, - token: session.token, - }, - }); -}); - -// Example: Protected route with requireAuth -authExampleRoute.get("/protected", requireAuth, async (c) => { - const user = c.get("user"); - - return c.json({ - message: `Hello, ${user.name}! This is a protected route.`, - userId: user.id, - }); -}); - -// Example: Public route -authExampleRoute.get("/public", async (c) => { - return c.json({ - message: "This is a public route. Anyone can access it.", - }); -}); - -export { authExampleRoute }; +import { Hono } from "hono"; +import { requireAuth } from "../middleware/auth"; + +const authExampleRoute = new Hono(); + +// Example: Get current user (protected route) +authExampleRoute.get("/me", requireAuth, async (c) => { + const user = c.get("user"); + const session = c.get("session"); + + return c.json({ + user: { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + session: { + id: session.id, + expiresAt: session.expiresAt, + token: session.token, + }, + }); +}); + +// Example: Protected route with requireAuth +authExampleRoute.get("/protected", requireAuth, async (c) => { + const user = c.get("user"); + + return c.json({ + message: `Hello, ${user.name}! This is a protected route.`, + userId: user.id, + }); +}); + +// Example: Public route +authExampleRoute.get("/public", async (c) => { + return c.json({ + message: "This is a public route. Anyone can access it.", + }); +}); + +export { authExampleRoute }; diff --git a/templates/auth/src/routes/auth.ts b/templates/auth/src/routes/auth.ts index 5e0113b..63c29bd 100644 --- a/templates/auth/src/routes/auth.ts +++ b/templates/auth/src/routes/auth.ts @@ -1,338 +1,338 @@ -import { Hono } from "hono"; -import { z } from "zod"; - -const authRoute = new Hono(); - -const magicLinkSchema = z.object({ - email: z.string().email(), -}); - -const otpSendSchema = z.object({ - email: z.string().email(), -}); - -const otpVerifySchema = z.object({ - email: z.string().email(), - code: z.string().length(6, "OTP must be 6 digits"), -}); - -// Two-Factor Authentication schemas -const mfaEnableSchema = z.object({ - code: z.string().length(6, "TOTP code must be 6 digits"), -}); - -const mfaVerifySchema = z.object({ - code: z.string().length(6, "TOTP code must be 6 digits"), -}); - -const mfaChallengeSchema = z.object({ - code: z.string().length(6, "TOTP code must be 6 digits"), -}); - -// Magic Link endpoints -authRoute.post("/magic-link/send", async (c) => { - 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 = magicLinkSchema.safeParse(rawBody); - if (!result.success) { - return c.json({ error: "Invalid payload", details: result.error.format() }, 400); - } - - const { email } = result.data; - const isDev = process.env.NODE_ENV === "development"; - - // In development, log the magic link - if (isDev) { - console.log( - `[DEV] Magic Link for ${email}: http://localhost:3000/auth/magic-link?token=dev-token-${Date.now()}`, - ); - } - - // TODO: Use better-auth's magic link API in production - // For now, return success (actual implementation would use better-auth's internal API) - return c.json({ message: "Magic link sent" }); -}); - -authRoute.get("/magic-link/verify", async (c) => { - const token = c.req.query("token"); - if (!token) { - return c.json({ error: "Token is required" }, 400); - } - - // TODO: Implement proper token verification using better-auth - // For now, simulate verification - if (token.startsWith("dev-token-")) { - // In dev mode, create a mock session - const sessionId = crypto.randomUUID(); - - // Find or create user (in real implementation, this would be done by better-auth) - return c.json({ - token: sessionId, - user: { - id: "dev-user-id", - email: "dev@example.com", - name: "Dev User", - }, - }); - } - - return c.json({ error: "Invalid or expired token" }, 401); -}); - -// OTP endpoints -authRoute.post("/otp/send", async (c) => { - 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 = otpSendSchema.safeParse(rawBody); - if (!result.success) { - return c.json({ error: "Invalid payload", details: result.error.format() }, 400); - } - - const { email } = result.data; - const isDev = process.env.NODE_ENV === "development"; - - // Generate 6-digit OTP - const otp = Math.floor(100000 + Math.random() * 900000).toString(); - - if (isDev) { - console.log(`[DEV] OTP for ${email}: ${otp}`); - } - - // TODO: Store OTP in database with expiry and send via email in production - return c.json({ message: "OTP sent successfully" }); -}); - -authRoute.post("/otp/verify", async (c) => { - 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 = otpVerifySchema.safeParse(rawBody); - if (!result.success) { - return c.json({ error: "Invalid payload", details: result.error.format() }, 400); - } - - const { email, code } = result.data; - - // TODO: Verify OTP from database in production - // For dev mode, accept any 6-digit code - if (process.env.NODE_ENV === "development" || code.length === 6) { - const sessionId = crypto.randomUUID(); - - return c.json({ - token: sessionId, - user: { - id: "otp-user-id", - email, - name: "OTP User", - }, - }); - } - - return c.json({ error: "Invalid or expired OTP" }, 401); -}); - -// Two-Factor Authentication endpoints -authRoute.post("/mfa/enable", async (c) => { - 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 = mfaEnableSchema.safeParse(rawBody); - if (!result.success) { - return c.json({ error: "Invalid payload", details: result.error.format() }, 400); - } - - // TODO: Implement actual MFA enable using better-auth twoFactor plugin - // Return QR URI and backup codes for TOTP setup - const qrUri = "otpauth://totp/BetterBase:user@example.com?secret=EXAMPLE&issuer=BetterBase"; - const backupCodes = Array.from({ length: 10 }, () => - Math.random().toString(36).substring(2, 10).toUpperCase(), - ); - - return c.json({ - qrUri, - backupCodes, - }); -}); - -authRoute.post("/mfa/verify", async (c) => { - 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 = mfaVerifySchema.safeParse(rawBody); - if (!result.success) { - return c.json({ error: "Invalid payload", details: result.error.format() }, 400); - } - - const { code } = result.data; - - // TODO: Verify TOTP code using better-auth - // Accept any 6-digit code in dev mode - if (process.env.NODE_ENV === "development" || code.length === 6) { - return c.json({ message: "MFA enabled successfully" }); - } - - return c.json({ error: "Invalid TOTP code" }, 401); -}); - -authRoute.post("/mfa/disable", async (c) => { - 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 = mfaVerifySchema.safeParse(rawBody); - if (!result.success) { - return c.json({ error: "Invalid payload", details: result.error.format() }, 400); - } - - const { code } = result.data; - - // TODO: Verify and disable MFA using better-auth - if (process.env.NODE_ENV === "development" || code.length === 6) { - return c.json({ message: "MFA disabled successfully" }); - } - - return c.json({ error: "Invalid TOTP code" }, 401); -}); - -authRoute.post("/mfa/challenge", async (c) => { - 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 = mfaChallengeSchema.safeParse(rawBody); - if (!result.success) { - return c.json({ error: "Invalid payload", details: result.error.format() }, 400); - } - - const { code } = result.data; - - // TODO: Verify TOTP code and return session using better-auth - // Accept any 6-digit code in dev mode - if (process.env.NODE_ENV === "development" || code.length === 6) { - const sessionId = crypto.randomUUID(); - return c.json({ - token: sessionId, - user: { - id: "mfa-user-id", - email: "user@example.com", - name: "MFA User", - }, - }); - } - - return c.json({ error: "Invalid TOTP code" }, 401); -}); - -// Phone / SMS Authentication endpoints -const phoneSendSchema = z.object({ - phone: z - .string() - .regex(/^\+[1-9]\d{1,14}$/, "Phone must be in E.164 format (e.g., +15555555555)"), -}); - -const phoneVerifySchema = z.object({ - phone: z.string().regex(/^\+[1-9]\d{1,14}$/, "Phone must be in E.164 format"), - code: z.string().length(6, "SMS code must be 6 digits"), -}); - -authRoute.post("/phone/send", async (c) => { - 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 = phoneSendSchema.safeParse(rawBody); - if (!result.success) { - return c.json({ error: "Invalid payload", details: result.error.format() }, 400); - } - - const { phone } = result.data; - const isDev = process.env.NODE_ENV === "development"; - - // Generate 6-digit code - const code = Math.floor(100000 + Math.random() * 900000).toString(); - - if (isDev) { - console.log(`[DEV] SMS for ${phone}: ${code}`); - // Never send real SMS in dev - } - - // TODO: Store hashed code with 10-min expiry in database - // TODO: Send via Twilio in production (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER) - - return c.json({ message: "SMS code sent successfully" }); -}); - -authRoute.post("/phone/verify", async (c) => { - 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 = phoneVerifySchema.safeParse(rawBody); - if (!result.success) { - return c.json({ error: "Invalid payload", details: result.error.format() }, 400); - } - - const { phone, code } = result.data; - - // TODO: Verify code from database with expiry check (10 minutes) - // Accept any 6-digit code in dev mode - if (process.env.NODE_ENV === "development" || code.length === 6) { - const sessionId = crypto.randomUUID(); - - return c.json({ - token: sessionId, - user: { - id: "phone-user-id", - email: `${phone}@phone.local`, - name: "Phone User", - }, - }); - } - - return c.json({ error: "Invalid or expired code" }, 401); -}); - -export { authRoute }; +import { Hono } from "hono"; +import { z } from "zod"; + +const authRoute = new Hono(); + +const magicLinkSchema = z.object({ + email: z.string().email(), +}); + +const otpSendSchema = z.object({ + email: z.string().email(), +}); + +const otpVerifySchema = z.object({ + email: z.string().email(), + code: z.string().length(6, "OTP must be 6 digits"), +}); + +// Two-Factor Authentication schemas +const mfaEnableSchema = z.object({ + code: z.string().length(6, "TOTP code must be 6 digits"), +}); + +const mfaVerifySchema = z.object({ + code: z.string().length(6, "TOTP code must be 6 digits"), +}); + +const mfaChallengeSchema = z.object({ + code: z.string().length(6, "TOTP code must be 6 digits"), +}); + +// Magic Link endpoints +authRoute.post("/magic-link/send", async (c) => { + 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 = magicLinkSchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: "Invalid payload", details: result.error.format() }, 400); + } + + const { email } = result.data; + const isDev = process.env.NODE_ENV === "development"; + + // In development, log the magic link + if (isDev) { + console.log( + `[DEV] Magic Link for ${email}: http://localhost:3000/auth/magic-link?token=dev-token-${Date.now()}`, + ); + } + + // TODO: Use better-auth's magic link API in production + // For now, return success (actual implementation would use better-auth's internal API) + return c.json({ message: "Magic link sent" }); +}); + +authRoute.get("/magic-link/verify", async (c) => { + const token = c.req.query("token"); + if (!token) { + return c.json({ error: "Token is required" }, 400); + } + + // TODO: Implement proper token verification using better-auth + // For now, simulate verification + if (token.startsWith("dev-token-")) { + // In dev mode, create a mock session + const sessionId = crypto.randomUUID(); + + // Find or create user (in real implementation, this would be done by better-auth) + return c.json({ + token: sessionId, + user: { + id: "dev-user-id", + email: "dev@example.com", + name: "Dev User", + }, + }); + } + + return c.json({ error: "Invalid or expired token" }, 401); +}); + +// OTP endpoints +authRoute.post("/otp/send", async (c) => { + 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 = otpSendSchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: "Invalid payload", details: result.error.format() }, 400); + } + + const { email } = result.data; + const isDev = process.env.NODE_ENV === "development"; + + // Generate 6-digit OTP + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + + if (isDev) { + console.log(`[DEV] OTP for ${email}: ${otp}`); + } + + // TODO: Store OTP in database with expiry and send via email in production + return c.json({ message: "OTP sent successfully" }); +}); + +authRoute.post("/otp/verify", async (c) => { + 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 = otpVerifySchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: "Invalid payload", details: result.error.format() }, 400); + } + + const { email, code } = result.data; + + // TODO: Verify OTP from database in production + // For dev mode, accept any 6-digit code + if (process.env.NODE_ENV === "development" || code.length === 6) { + const sessionId = crypto.randomUUID(); + + return c.json({ + token: sessionId, + user: { + id: "otp-user-id", + email, + name: "OTP User", + }, + }); + } + + return c.json({ error: "Invalid or expired OTP" }, 401); +}); + +// Two-Factor Authentication endpoints +authRoute.post("/mfa/enable", async (c) => { + 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 = mfaEnableSchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: "Invalid payload", details: result.error.format() }, 400); + } + + // TODO: Implement actual MFA enable using better-auth twoFactor plugin + // Return QR URI and backup codes for TOTP setup + const qrUri = "otpauth://totp/BetterBase:user@example.com?secret=EXAMPLE&issuer=BetterBase"; + const backupCodes = Array.from({ length: 10 }, () => + Math.random().toString(36).substring(2, 10).toUpperCase(), + ); + + return c.json({ + qrUri, + backupCodes, + }); +}); + +authRoute.post("/mfa/verify", async (c) => { + 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 = mfaVerifySchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: "Invalid payload", details: result.error.format() }, 400); + } + + const { code } = result.data; + + // TODO: Verify TOTP code using better-auth + // Accept any 6-digit code in dev mode + if (process.env.NODE_ENV === "development" || code.length === 6) { + return c.json({ message: "MFA enabled successfully" }); + } + + return c.json({ error: "Invalid TOTP code" }, 401); +}); + +authRoute.post("/mfa/disable", async (c) => { + 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 = mfaVerifySchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: "Invalid payload", details: result.error.format() }, 400); + } + + const { code } = result.data; + + // TODO: Verify and disable MFA using better-auth + if (process.env.NODE_ENV === "development" || code.length === 6) { + return c.json({ message: "MFA disabled successfully" }); + } + + return c.json({ error: "Invalid TOTP code" }, 401); +}); + +authRoute.post("/mfa/challenge", async (c) => { + 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 = mfaChallengeSchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: "Invalid payload", details: result.error.format() }, 400); + } + + const { code } = result.data; + + // TODO: Verify TOTP code and return session using better-auth + // Accept any 6-digit code in dev mode + if (process.env.NODE_ENV === "development" || code.length === 6) { + const sessionId = crypto.randomUUID(); + return c.json({ + token: sessionId, + user: { + id: "mfa-user-id", + email: "user@example.com", + name: "MFA User", + }, + }); + } + + return c.json({ error: "Invalid TOTP code" }, 401); +}); + +// Phone / SMS Authentication endpoints +const phoneSendSchema = z.object({ + phone: z + .string() + .regex(/^\+[1-9]\d{1,14}$/, "Phone must be in E.164 format (e.g., +15555555555)"), +}); + +const phoneVerifySchema = z.object({ + phone: z.string().regex(/^\+[1-9]\d{1,14}$/, "Phone must be in E.164 format"), + code: z.string().length(6, "SMS code must be 6 digits"), +}); + +authRoute.post("/phone/send", async (c) => { + 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 = phoneSendSchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: "Invalid payload", details: result.error.format() }, 400); + } + + const { phone } = result.data; + const isDev = process.env.NODE_ENV === "development"; + + // Generate 6-digit code + const code = Math.floor(100000 + Math.random() * 900000).toString(); + + if (isDev) { + console.log(`[DEV] SMS for ${phone}: ${code}`); + // Never send real SMS in dev + } + + // TODO: Store hashed code with 10-min expiry in database + // TODO: Send via Twilio in production (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER) + + return c.json({ message: "SMS code sent successfully" }); +}); + +authRoute.post("/phone/verify", async (c) => { + 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 = phoneVerifySchema.safeParse(rawBody); + if (!result.success) { + return c.json({ error: "Invalid payload", details: result.error.format() }, 400); + } + + const { phone, code } = result.data; + + // TODO: Verify code from database with expiry check (10 minutes) + // Accept any 6-digit code in dev mode + if (process.env.NODE_ENV === "development" || code.length === 6) { + const sessionId = crypto.randomUUID(); + + return c.json({ + token: sessionId, + user: { + id: "phone-user-id", + email: `${phone}@phone.local`, + name: "Phone User", + }, + }); + } + + return c.json({ error: "Invalid or expired code" }, 401); +}); + +export { authRoute }; diff --git a/templates/base/betterbase.config.ts b/templates/base/betterbase.config.ts index 120f6dd..2fa1a62 100644 --- a/templates/base/betterbase.config.ts +++ b/templates/base/betterbase.config.ts @@ -1,107 +1,107 @@ -/** - * BetterBase Configuration File - * - * This file defines the configuration for your BetterBase project. - * Update the values below to match your project requirements. - * - * Required environment variables: - * - DATABASE_URL: Connection string for your database (for neon, postgres, supabase, planetscale) - * - TURSO_URL: libSQL connection URL (for turso) - * - TURSO_AUTH_TOKEN: Auth token for Turso database (for turso) - */ - -import type { BetterBaseConfig } from "@betterbase/core"; - -/** - * BetterBase Project Configuration - * - * @example - * ```typescript - * export default { - * project: { - * name: 'my-betterbase-app', - * }, - * provider: { - * type: 'postgres', - * connectionString: process.env.DATABASE_URL, - * }, - * } satisfies BetterBaseConfig - * ``` - */ -export default { - /** Project name - used for identification and metadata */ - project: { - name: "my-betterbase-app", - }, - - /** - * Database provider configuration - * - * Supported providers: - * - 'postgres': Standard PostgreSQL (uses DATABASE_URL) - * - 'neon': Neon serverless PostgreSQL (uses DATABASE_URL) - * - 'supabase': Supabase PostgreSQL (uses DATABASE_URL) - * - 'planetscale': PlanetScale MySQL (uses DATABASE_URL) - * - 'turso': Turso libSQL (uses TURSO_URL and TURSO_AUTH_TOKEN) - * - 'managed': BetterBase managed database (uses DATABASE_URL or defaults to local.db) - */ - provider: { - /** The database provider type */ - type: "postgres" as const, - - /** - * Database connection string (for postgres, neon, supabase, planetscale) - * Format: postgresql://user:pass@host:port/db for PostgreSQL - * Format: mysql://user:pass@host:port/db for MySQL/PlanetScale - */ - connectionString: process.env.DATABASE_URL, - - // Turso-specific (uncomment if using Turso): - // url: process.env.TURSO_URL, - // authToken: process.env.TURSO_AUTH_TOKEN, - }, - - /** - * Storage configuration (Phase 14) - * Uncomment and configure when implementing file storage - */ - // storage: { - // provider: 's3', // 's3' | 'r2' | 'backblaze' | 'minio' | 'managed' - // bucket: 'my-bucket', - // region: 'us-east-1', - // // For S3-compatible providers: - // // endpoint: 'https://s3.amazonaws.com', - // }, - - /** - * Webhook configuration (Phase 13) - * Uncomment and configure when implementing webhooks - */ - // webhooks: [ - // { - // id: 'webhook-1', - // table: 'users', - // events: ['INSERT', 'UPDATE', 'DELETE'], - // url: 'https://example.com/webhook', - // secret: process.env.WEBHOOK_SECRET!, - // enabled: true, - // }, - // ], - - /** - * GraphQL API configuration - * Set enabled: false to disable the GraphQL API - */ - graphql: { - enabled: true, - }, - - /** - * Auto-REST API configuration - * Automatically generates CRUD routes for all tables in the schema - */ - autoRest: { - enabled: true, - excludeTables: [], - }, -} satisfies BetterBaseConfig; +/** + * BetterBase Configuration File + * + * This file defines the configuration for your BetterBase project. + * Update the values below to match your project requirements. + * + * Required environment variables: + * - DATABASE_URL: Connection string for your database (for neon, postgres, supabase, planetscale) + * - TURSO_URL: libSQL connection URL (for turso) + * - TURSO_AUTH_TOKEN: Auth token for Turso database (for turso) + */ + +import type { BetterBaseConfig } from "@betterbase/core"; + +/** + * BetterBase Project Configuration + * + * @example + * ```typescript + * export default { + * project: { + * name: 'my-betterbase-app', + * }, + * provider: { + * type: 'postgres', + * connectionString: process.env.DATABASE_URL, + * }, + * } satisfies BetterBaseConfig + * ``` + */ +export default { + /** Project name - used for identification and metadata */ + project: { + name: "my-betterbase-app", + }, + + /** + * Database provider configuration + * + * Supported providers: + * - 'postgres': Standard PostgreSQL (uses DATABASE_URL) + * - 'neon': Neon serverless PostgreSQL (uses DATABASE_URL) + * - 'supabase': Supabase PostgreSQL (uses DATABASE_URL) + * - 'planetscale': PlanetScale MySQL (uses DATABASE_URL) + * - 'turso': Turso libSQL (uses TURSO_URL and TURSO_AUTH_TOKEN) + * - 'managed': BetterBase managed database (uses DATABASE_URL or defaults to local.db) + */ + provider: { + /** The database provider type */ + type: "postgres" as const, + + /** + * Database connection string (for postgres, neon, supabase, planetscale) + * Format: postgresql://user:pass@host:port/db for PostgreSQL + * Format: mysql://user:pass@host:port/db for MySQL/PlanetScale + */ + connectionString: process.env.DATABASE_URL, + + // Turso-specific (uncomment if using Turso): + // url: process.env.TURSO_URL, + // authToken: process.env.TURSO_AUTH_TOKEN, + }, + + /** + * Storage configuration (Phase 14) + * Uncomment and configure when implementing file storage + */ + // storage: { + // provider: 's3', // 's3' | 'r2' | 'backblaze' | 'minio' | 'managed' + // bucket: 'my-bucket', + // region: 'us-east-1', + // // For S3-compatible providers: + // // endpoint: 'https://s3.amazonaws.com', + // }, + + /** + * Webhook configuration (Phase 13) + * Uncomment and configure when implementing webhooks + */ + // webhooks: [ + // { + // id: 'webhook-1', + // table: 'users', + // events: ['INSERT', 'UPDATE', 'DELETE'], + // url: 'https://example.com/webhook', + // secret: process.env.WEBHOOK_SECRET!, + // enabled: true, + // }, + // ], + + /** + * GraphQL API configuration + * Set enabled: false to disable the GraphQL API + */ + graphql: { + enabled: true, + }, + + /** + * Auto-REST API configuration + * Automatically generates CRUD routes for all tables in the schema + */ + autoRest: { + enabled: true, + excludeTables: [], + }, +} satisfies BetterBaseConfig; diff --git a/templates/base/drizzle.config.ts b/templates/base/drizzle.config.ts index 3e76c50..fd8d66c 100644 --- a/templates/base/drizzle.config.ts +++ b/templates/base/drizzle.config.ts @@ -1,12 +1,12 @@ -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - schema: "./src/db/schema.ts", - out: "./drizzle", - dialect: "sqlite", - dbCredentials: { - url: "file:local.db", - }, - verbose: true, - strict: true, -}); +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle", + dialect: "sqlite", + dbCredentials: { + url: "file:local.db", + }, + verbose: true, + strict: true, +}); diff --git a/templates/base/src/auth/index.ts b/templates/base/src/auth/index.ts index e367e78..7f95390 100644 --- a/templates/base/src/auth/index.ts +++ b/templates/base/src/auth/index.ts @@ -1,44 +1,44 @@ -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { magicLink } from "better-auth/plugins/magic-link"; -import { twoFactor } from "better-auth/plugins/two-factor"; -import { db } from "../db"; -import * as schema from "../db/schema"; - -// Development mode: log magic links instead of sending -const isDev = process.env.NODE_ENV === "development"; - -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "sqlite", - schema: { - user: schema.user, - session: schema.session, - account: schema.account, - verification: schema.verification, - }, - }), - emailAndPassword: { - enabled: true, - requireEmailVerification: false, - }, - secret: process.env.AUTH_SECRET, - baseURL: process.env.AUTH_URL ?? "http://localhost:3000", - trustedOrigins: [process.env.AUTH_URL ?? "http://localhost:3000"], - plugins: [ - magicLink({ - sendMagicLink: async ({ email, url }) => { - if (isDev) { - console.log(`[DEV] Magic Link for ${email}: ${url}`); - return; - } - // In production, send email using SMTP config - // SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM - console.log(`[PROD] Magic Link would be sent to ${email}: ${url}`); - }, - }), - twoFactor(), - ], -}); - -export type Auth = typeof auth; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { magicLink } from "better-auth/plugins/magic-link"; +import { twoFactor } from "better-auth/plugins/two-factor"; +import { db } from "../db"; +import * as schema from "../db/schema"; + +// Development mode: log magic links instead of sending +const isDev = process.env.NODE_ENV === "development"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "sqlite", + schema: { + user: schema.user, + session: schema.session, + account: schema.account, + verification: schema.verification, + }, + }), + emailAndPassword: { + enabled: true, + requireEmailVerification: false, + }, + secret: process.env.AUTH_SECRET, + baseURL: process.env.AUTH_URL ?? "http://localhost:3000", + trustedOrigins: [process.env.AUTH_URL ?? "http://localhost:3000"], + plugins: [ + magicLink({ + sendMagicLink: async ({ email, url }) => { + if (isDev) { + console.log(`[DEV] Magic Link for ${email}: ${url}`); + return; + } + // In production, send email using SMTP config + // SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM + console.log(`[PROD] Magic Link would be sent to ${email}: ${url}`); + }, + }), + twoFactor(), + ], +}); + +export type Auth = typeof auth; diff --git a/templates/base/src/auth/types.ts b/templates/base/src/auth/types.ts index 2a33da9..0ae559c 100644 --- a/templates/base/src/auth/types.ts +++ b/templates/base/src/auth/types.ts @@ -1,9 +1,9 @@ -import type { auth } from "./index"; - -export type Session = typeof auth.$Infer.Session.session; -export type User = typeof auth.$Infer.Session.user; - -export type AuthVariables = { - user: User; - session: Session; -}; +import type { auth } from "./index"; + +export type Session = typeof auth.$Infer.Session.session; +export type User = typeof auth.$Infer.Session.user; + +export type AuthVariables = { + user: User; + session: Session; +}; diff --git a/templates/base/src/db/index.ts b/templates/base/src/db/index.ts index f027283..1afc0d1 100644 --- a/templates/base/src/db/index.ts +++ b/templates/base/src/db/index.ts @@ -1,9 +1,9 @@ -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { env } from "../lib/env"; -import * as schema from "./schema"; - -// env.DB_PATH is always present because env schema provides a default. -const sqlite = new Database(env.DB_PATH, { create: true }); - -export const db = drizzle(sqlite, { schema }); +import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { env } from "../lib/env"; +import * as schema from "./schema"; + +// env.DB_PATH is always present because env schema provides a default. +const sqlite = new Database(env.DB_PATH, { create: true }); + +export const db = drizzle(sqlite, { schema }); diff --git a/templates/base/src/db/migrate.ts b/templates/base/src/db/migrate.ts index 0b09c1a..3a4ddbc 100644 --- a/templates/base/src/db/migrate.ts +++ b/templates/base/src/db/migrate.ts @@ -1,16 +1,16 @@ -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { env } from "../lib/env"; - -try { - const sqlite = new Database(env.DB_PATH, { create: true }); - const db = drizzle(sqlite); - - migrate(db, { migrationsFolder: "./drizzle" }); - console.log("Migrations applied successfully."); -} catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error("Failed to apply migrations:", message); - process.exit(1); -} +import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { env } from "../lib/env"; + +try { + const sqlite = new Database(env.DB_PATH, { create: true }); + const db = drizzle(sqlite); + + migrate(db, { migrationsFolder: "./drizzle" }); + console.log("Migrations applied successfully."); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("Failed to apply migrations:", message); + process.exit(1); +} diff --git a/templates/base/src/db/schema.ts b/templates/base/src/db/schema.ts index 5adafbc..183ee74 100644 --- a/templates/base/src/db/schema.ts +++ b/templates/base/src/db/schema.ts @@ -1,124 +1,124 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; - -/** - * Adds created_at and updated_at timestamp columns. - * created_at is set on insert and updated_at is refreshed on updates. - * Note: .$onUpdate(() => new Date()) applies when updates go through Drizzle. - * Raw SQL writes will not auto-update this value without a DB trigger. - * - * @example - * export const users = sqliteTable('users', { - * id: uuid(), - * email: text('email'), - * ...timestamps, - * }); - */ -export const timestamps = { - createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .$defaultFn(() => new Date()) - .$onUpdate(() => new Date()), -}; - -/** - * UUID primary-key helper. - */ -export const uuid = (name = "id") => - text(name) - .primaryKey() - .$defaultFn(() => crypto.randomUUID()); - -/** - * Soft-delete helper. - */ -export const softDelete = { - deletedAt: integer("deleted_at", { mode: "timestamp" }), -}; - -/** - * Shared status enum helper. - */ -export const statusEnum = (name = "status") => - text(name, { enum: ["active", "inactive", "pending"] }).default("active"); - -/** - * Currency helper stored as integer cents. - */ -export const moneyColumn = (name: string) => integer(name).notNull().default(0); - -/** - * JSON text helper with type support. - */ -export const jsonColumn = (name: string) => text(name, { mode: "json" }).$type(); - -export const users = sqliteTable("users", { - id: uuid(), - email: text("email").notNull().unique(), - name: text("name"), - status: statusEnum(), - ...timestamps, - ...softDelete, -}); - -export const posts = sqliteTable("posts", { - id: uuid(), - title: text("title").notNull(), - content: text("content"), - userId: text("user_id").references(() => users.id), - ...timestamps, -}); - -// BetterAuth tables -export const user = sqliteTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false), - image: text("image"), - createdAt: integer("created_at", { mode: "timestamp" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), -}); - -export const session = sqliteTable("session", { - id: text("id").primaryKey(), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), - token: text("token").notNull().unique(), - createdAt: integer("created_at", { mode: "timestamp" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), -}); - -export const account = sqliteTable("account", { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: integer("access_token_expires_at", { - mode: "timestamp", - }), - refreshTokenExpiresAt: integer("refresh_token_expires_at", { - mode: "timestamp", - }), - scope: text("scope"), - password: text("password"), - createdAt: integer("created_at", { mode: "timestamp" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), -}); - -export const verification = sqliteTable("verification", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), - createdAt: integer("created_at", { mode: "timestamp" }), - updatedAt: integer("updated_at", { mode: "timestamp" }), -}); +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +/** + * Adds created_at and updated_at timestamp columns. + * created_at is set on insert and updated_at is refreshed on updates. + * Note: .$onUpdate(() => new Date()) applies when updates go through Drizzle. + * Raw SQL writes will not auto-update this value without a DB trigger. + * + * @example + * export const users = sqliteTable('users', { + * id: uuid(), + * email: text('email'), + * ...timestamps, + * }); + */ +export const timestamps = { + createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), +}; + +/** + * UUID primary-key helper. + */ +export const uuid = (name = "id") => + text(name) + .primaryKey() + .$defaultFn(() => crypto.randomUUID()); + +/** + * Soft-delete helper. + */ +export const softDelete = { + deletedAt: integer("deleted_at", { mode: "timestamp" }), +}; + +/** + * Shared status enum helper. + */ +export const statusEnum = (name = "status") => + text(name, { enum: ["active", "inactive", "pending"] }).default("active"); + +/** + * Currency helper stored as integer cents. + */ +export const moneyColumn = (name: string) => integer(name).notNull().default(0); + +/** + * JSON text helper with type support. + */ +export const jsonColumn = (name: string) => text(name, { mode: "json" }).$type(); + +export const users = sqliteTable("users", { + id: uuid(), + email: text("email").notNull().unique(), + name: text("name"), + status: statusEnum(), + ...timestamps, + ...softDelete, +}); + +export const posts = sqliteTable("posts", { + id: uuid(), + title: text("title").notNull(), + content: text("content"), + userId: text("user_id").references(() => users.id), + ...timestamps, +}); + +// BetterAuth tables +export const user = sqliteTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false), + image: text("image"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), +}); + +export const session = sqliteTable("session", { + id: text("id").primaryKey(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + token: text("token").notNull().unique(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const account = sqliteTable("account", { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: integer("access_token_expires_at", { + mode: "timestamp", + }), + refreshTokenExpiresAt: integer("refresh_token_expires_at", { + mode: "timestamp", + }), + scope: text("scope"), + password: text("password"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), +}); + +export const verification = sqliteTable("verification", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp" }), + updatedAt: integer("updated_at", { mode: "timestamp" }), +}); diff --git a/templates/base/src/index.ts b/templates/base/src/index.ts index 697508d..1a413ff 100644 --- a/templates/base/src/index.ts +++ b/templates/base/src/index.ts @@ -1,149 +1,149 @@ -import { EventEmitter } from "node:events"; -import { type AutoRestOptions, mountAutoRest } from "@betterbase/core"; -import { initializeWebhooks } from "@betterbase/core/webhooks"; -import { Hono } from "hono"; -import { upgradeWebSocket, websocket } from "hono/bun"; -import config from "../betterbase.config"; -import { auth } from "./auth"; -import { env } from "./lib/env"; -import { realtime } from "./lib/realtime"; -import { registerRoutes } from "./routes"; - -const app = new Hono(); - -// Create an event emitter for database changes (used by webhooks) -const dbEventEmitter = new EventEmitter(); - -app.get( - "/ws", - upgradeWebSocket((c) => { - const authHeaderToken = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); - // Prefer Authorization header. Query token is compatibility fallback and should be short-lived in production. - const queryToken = c.req.query("token"); - const token = authHeaderToken ?? queryToken; - - if (!authHeaderToken && queryToken) { - console.warn( - "WebSocket auth using query token fallback; prefer header/cookie/subprotocol in production.", - ); - } - - return { - onOpen(_event, ws) { - realtime.handleConnection(ws.raw, token); - }, - onMessage(event, ws) { - const message = typeof event.data === "string" ? event.data : event.data.toString(); - realtime.handleMessage(ws.raw, message); - }, - onClose(_event, ws) { - realtime.handleClose(ws.raw); - }, - }; - }), -); - -registerRoutes(app); - -app.on(["POST", "GET"], "/api/auth/**", (c) => { - return auth.handler(c.req.raw); -}); - -// Mount GraphQL API if enabled -const graphqlEnabled = config.graphql?.enabled ?? true; -if (graphqlEnabled) { - // Dynamic import to handle case where graphql route doesn't exist yet - try { - // Using require for dynamic loading of potentially non-existent module - // eslint-disable-next-line @typescript-eslint/no-var-requires - const graphql = require("./routes/graphql"); - const graphqlRoute = graphql.graphqlRoute as ReturnType< - typeof import("hono").Hono.prototype.route - >; - app.route("/", graphqlRoute); - console.log("🛸 GraphQL API enabled at /api/graphql"); - } catch { - // GraphQL route not generated yet - if (env.NODE_ENV === "development") { - console.log('ℹ️ Run "bb graphql generate" to enable GraphQL API'); - } - } -} - -// Mount Auto-REST API if enabled -const autoRestEnabled = config.autoRest?.enabled ?? true; -if (autoRestEnabled) { - let dbModule: { schema?: unknown; db?: unknown } | null = null; - let schema: unknown; - - try { - // Dynamic import to handle case where db module may not exist - // eslint-disable-next-line @typescript-eslint/no-var-requires - dbModule = require("./db"); - schema = dbModule?.schema; - } catch (error) { - // Module doesn't exist - this is expected in development without DB setup - if (env.NODE_ENV === "development") { - console.log("ℹ️ Auto-REST requires a database schema to be defined"); - } - dbModule = null; - } - - // Check if schema is absent/undefined after module loaded - if (!schema && dbModule === null) { - // Module missing - expected in some configurations - if (env.NODE_ENV === "development") { - console.log("ℹ️ Auto-REST requires a database schema to be defined"); - } - } else if (!schema) { - // Schema is undefined - expected when db module exists but has no schema - if (env.NODE_ENV === "development") { - console.log("ℹ️ Auto-REST requires a database schema to be defined"); - } - } else if (dbModule?.db && schema) { - // Both db and schema exist - mount Auto-REST - mountAutoRest(app, dbModule.db, schema, { - enabled: true, - excludeTables: config.autoRest?.excludeTables ?? [], - basePath: "/api", - enableRLS: true, - }); - console.log("⚡ Auto-REST API enabled"); - } else { - // db module exists but db or schema is missing - rethrow - throw new Error("Database module or schema not properly configured"); - } -} - -// Initialize webhooks (Phase 13) -initializeWebhooks(config, dbEventEmitter); - -// Webhook logs API endpoint (for CLI access) -app.get("/api/webhooks/:id/logs", async (c) => { - const webhookId = c.req.param("id"); - // In a full implementation, this would fetch logs from the dispatcher - // For now, return a placeholder - return c.json({ logs: [], message: "Logs not available via API in v1" }); -}); - -const server = Bun.serve({ - fetch: app.fetch, - websocket, - port: env.PORT, - development: env.NODE_ENV === "development", -}); - -console.log(`🚀 Server running at http://localhost:${server.port}`); -for (const route of app.routes) { - console.log(` ${route.method} ${route.path}`); -} - -process.on("SIGTERM", () => { - server.stop(); -}); - -process.on("SIGINT", () => { - server.stop(); -}); - -export { app, server, dbEventEmitter }; +import { EventEmitter } from "node:events"; +import { type AutoRestOptions, mountAutoRest } from "@betterbase/core"; +import { initializeWebhooks } from "@betterbase/core/webhooks"; +import { Hono } from "hono"; +import { upgradeWebSocket, websocket } from "hono/bun"; +import config from "../betterbase.config"; +import { auth } from "./auth"; +import { env } from "./lib/env"; +import { realtime } from "./lib/realtime"; +import { registerRoutes } from "./routes"; + +const app = new Hono(); + +// Create an event emitter for database changes (used by webhooks) +const dbEventEmitter = new EventEmitter(); + +app.get( + "/ws", + upgradeWebSocket((c) => { + const authHeaderToken = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); + // Prefer Authorization header. Query token is compatibility fallback and should be short-lived in production. + const queryToken = c.req.query("token"); + const token = authHeaderToken ?? queryToken; + + if (!authHeaderToken && queryToken) { + console.warn( + "WebSocket auth using query token fallback; prefer header/cookie/subprotocol in production.", + ); + } + + return { + onOpen(_event, ws) { + realtime.handleConnection(ws.raw, token); + }, + onMessage(event, ws) { + const message = typeof event.data === "string" ? event.data : event.data.toString(); + realtime.handleMessage(ws.raw, message); + }, + onClose(_event, ws) { + realtime.handleClose(ws.raw); + }, + }; + }), +); + +registerRoutes(app); + +app.on(["POST", "GET"], "/api/auth/**", (c) => { + return auth.handler(c.req.raw); +}); + +// Mount GraphQL API if enabled +const graphqlEnabled = config.graphql?.enabled ?? true; +if (graphqlEnabled) { + // Dynamic import to handle case where graphql route doesn't exist yet + try { + // Using require for dynamic loading of potentially non-existent module + // eslint-disable-next-line @typescript-eslint/no-var-requires + const graphql = require("./routes/graphql"); + const graphqlRoute = graphql.graphqlRoute as ReturnType< + typeof import("hono").Hono.prototype.route + >; + app.route("/", graphqlRoute); + console.log("🛸 GraphQL API enabled at /api/graphql"); + } catch { + // GraphQL route not generated yet + if (env.NODE_ENV === "development") { + console.log('ℹ️ Run "bb graphql generate" to enable GraphQL API'); + } + } +} + +// Mount Auto-REST API if enabled +const autoRestEnabled = config.autoRest?.enabled ?? true; +if (autoRestEnabled) { + let dbModule: { schema?: unknown; db?: unknown } | null = null; + let schema: unknown; + + try { + // Dynamic import to handle case where db module may not exist + // eslint-disable-next-line @typescript-eslint/no-var-requires + dbModule = require("./db"); + schema = dbModule?.schema; + } catch (error) { + // Module doesn't exist - this is expected in development without DB setup + if (env.NODE_ENV === "development") { + console.log("ℹ️ Auto-REST requires a database schema to be defined"); + } + dbModule = null; + } + + // Check if schema is absent/undefined after module loaded + if (!schema && dbModule === null) { + // Module missing - expected in some configurations + if (env.NODE_ENV === "development") { + console.log("ℹ️ Auto-REST requires a database schema to be defined"); + } + } else if (!schema) { + // Schema is undefined - expected when db module exists but has no schema + if (env.NODE_ENV === "development") { + console.log("ℹ️ Auto-REST requires a database schema to be defined"); + } + } else if (dbModule?.db && schema) { + // Both db and schema exist - mount Auto-REST + mountAutoRest(app, dbModule.db, schema, { + enabled: true, + excludeTables: config.autoRest?.excludeTables ?? [], + basePath: "/api", + enableRLS: true, + }); + console.log("⚡ Auto-REST API enabled"); + } else { + // db module exists but db or schema is missing - rethrow + throw new Error("Database module or schema not properly configured"); + } +} + +// Initialize webhooks (Phase 13) +initializeWebhooks(config, dbEventEmitter); + +// Webhook logs API endpoint (for CLI access) +app.get("/api/webhooks/:id/logs", async (c) => { + const webhookId = c.req.param("id"); + // In a full implementation, this would fetch logs from the dispatcher + // For now, return a placeholder + return c.json({ logs: [], message: "Logs not available via API in v1" }); +}); + +const server = Bun.serve({ + fetch: app.fetch, + websocket, + port: env.PORT, + development: env.NODE_ENV === "development", +}); + +console.log(`🚀 Server running at http://localhost:${server.port}`); +for (const route of app.routes) { + console.log(` ${route.method} ${route.path}`); +} + +process.on("SIGTERM", () => { + server.stop(); +}); + +process.on("SIGINT", () => { + server.stop(); +}); + +export { app, server, dbEventEmitter }; diff --git a/templates/base/src/lib/env.ts b/templates/base/src/lib/env.ts index cf1bbf5..3390863 100644 --- a/templates/base/src/lib/env.ts +++ b/templates/base/src/lib/env.ts @@ -1,12 +1,12 @@ -import { z } from "zod"; - -// Keep in sync with packages/cli/src/constants.ts DEFAULT_DB_PATH. -export const DEFAULT_DB_PATH = "local.db"; - -const envSchema = z.object({ - NODE_ENV: z.enum(["development", "test", "production"]).default("development"), - PORT: z.coerce.number().int().positive().default(3000), - DB_PATH: z.string().min(1).default(DEFAULT_DB_PATH), -}); - -export const env = envSchema.parse(process.env); +import { z } from "zod"; + +// Keep in sync with packages/cli/src/constants.ts DEFAULT_DB_PATH. +export const DEFAULT_DB_PATH = "local.db"; + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + PORT: z.coerce.number().int().positive().default(3000), + DB_PATH: z.string().min(1).default(DEFAULT_DB_PATH), +}); + +export const env = envSchema.parse(process.env); diff --git a/templates/base/src/lib/realtime.ts b/templates/base/src/lib/realtime.ts index e38b4d0..5512a7a 100644 --- a/templates/base/src/lib/realtime.ts +++ b/templates/base/src/lib/realtime.ts @@ -1,378 +1,378 @@ -import type { DBEvent } from "@betterbase/shared"; -import type { ServerWebSocket } from "bun"; -import deepEqual from "fast-deep-equal"; -import { z } from "zod"; - -export interface Subscription { - table: string; - event: "INSERT" | "UPDATE" | "DELETE" | "*"; - filter?: Record; -} - -interface Client { - ws: ServerWebSocket; - userId: string; - claims: string[]; - subscriptions: Map; -} - -interface RealtimeUpdatePayload { - type: "update"; - table: string; - event: "INSERT" | "UPDATE" | "DELETE"; - data: unknown; - timestamp: string; -} - -interface RealtimeConfig { - maxClients: number; - maxSubscriptionsPerClient: number; - maxSubscribersPerTable: number; -} - -const messageSchema = z.union([ - z.object({ - type: z.literal("subscribe"), - table: z.string().min(1).max(255), - event: z.enum(["INSERT", "UPDATE", "DELETE", "*"]).default("*"), - filter: z.record(z.string(), z.unknown()).optional(), - }), - z.object({ - type: z.literal("unsubscribe"), - table: z.string().min(1).max(255), - event: z.enum(["INSERT", "UPDATE", "DELETE", "*"]).default("*"), - }), -]); - -const realtimeLogger = { - debug: (message: string): void => console.debug(`[realtime] ${message}`), - info: (message: string): void => console.info(`[realtime] ${message}`), - warn: (message: string): void => console.warn(`[realtime] ${message}`), -}; - -export class RealtimeServer { - private clients = new Map, Client>(); - private tableSubscribers = new Map>>(); - private config: RealtimeConfig; - // CDC event handler for automatic database change events - private cdcCallback: ((event: DBEvent) => void) | null = null; - - // Map to track subscriptions by table+event for efficient filtering - // Key format: "table:event" (e.g., "users:INSERT") - private tableEventSubscribers = new Map>>(); - - constructor(config?: Partial) { - if (process.env.NODE_ENV !== "development" && process.env.ENABLE_DEV_AUTH !== "true") { - realtimeLogger.warn( - "Realtime auth verifier is not configured; dev token parser is disabled. Configure a real verifier for production.", - ); - } - - this.config = { - maxClients: 1000, - maxSubscriptionsPerClient: 50, - maxSubscribersPerTable: 500, - ...config, - }; - } - - /** - * Connect to database change events (CDC) - * This enables automatic event emission when database changes occur - * @param onchange - Callback function that receives DBEvent when data changes - */ - connectCDC(onchange: (event: DBEvent) => void): void { - this.cdcCallback = onchange; - } - - /** - * Handle a database change event from CDC - * This is called automatically when the database emits change events - */ - private handleCDCEvent(event: DBEvent): void { - // Invoke the CDC callback if registered - this.cdcCallback?.(event); - // Broadcast the event to subscribed clients via WebSocket - this.broadcast(event.table, event.type, event.record); - } - - /** - * Process a CDC event and broadcast to WebSocket clients - * Server-side filtering: only delivers to clients with matching subscriptions - */ - processCDCEvent(event: DBEvent): void { - // Invoke the CDC callback if registered - this.cdcCallback?.(event); - // Broadcast to WebSocket clients with server-side filtering - this.broadcast(event.table, event.type, event.record); - } - - /** - * Get subscribers for a specific table and event type - * This enables server-side filtering - */ - private getSubscribersForEvent( - table: string, - event: "INSERT" | "UPDATE" | "DELETE", - ): Set> { - const subscribers = new Set>(); - - // Get exact match subscribers (table + event) - const exactKey = `${table}:${event}`; - const exactSubs = this.tableEventSubscribers.get(exactKey); - if (exactSubs) { - for (const ws of exactSubs) { - subscribers.add(ws); - } - } - - // Get wildcard subscribers (table + *) - const wildcardKey = `${table}:*`; - const wildcardSubs = this.tableEventSubscribers.get(wildcardKey); - if (wildcardSubs) { - for (const ws of wildcardSubs) { - subscribers.add(ws); - } - } - - return subscribers; - } - - authenticate(token: string | undefined): { userId: string; claims: string[] } | null { - if (!token || !token.trim()) return null; - - const allowDevAuth = - process.env.NODE_ENV === "development" || process.env.ENABLE_DEV_AUTH === "true"; - if (!allowDevAuth) { - return null; - } - - const [userId, rawClaims] = token.trim().split(":", 2); - if (!userId) return null; - - const claims = rawClaims - ? rawClaims - .split(",") - .map((claim) => claim.trim()) - .filter(Boolean) - : []; - return { userId, claims }; - } - - authorize(userId: string, claims: string[], table: string): boolean { - return ( - Boolean(userId) && (claims.includes("realtime:*") || claims.includes(`realtime:${table}`)) - ); - } - - handleConnection(ws: ServerWebSocket, token: string | undefined): boolean { - if (this.clients.size >= this.config.maxClients) { - realtimeLogger.warn("Rejecting realtime connection: max clients reached"); - this.safeSend(ws, { error: "Server is busy. Try again later." }); - ws.close(1013, "Server busy"); - return false; - } - - const identity = this.authenticate(token); - if (!identity) { - realtimeLogger.warn("Rejecting unauthenticated realtime connection"); - this.safeSend(ws, { error: "Unauthorized websocket connection" }); - ws.close(1008, "Unauthorized"); - return false; - } - - realtimeLogger.info(`Client connected (${identity.userId})`); - this.clients.set(ws, { - ws, - userId: identity.userId, - claims: identity.claims, - subscriptions: new Map(), - }); - - return true; - } - - handleMessage(ws: ServerWebSocket, rawMessage: string): void { - let parsedJson: unknown; - - try { - parsedJson = JSON.parse(rawMessage); - } catch { - this.safeSend(ws, { error: "Invalid message format" }); - return; - } - - const result = messageSchema.safeParse(parsedJson); - if (!result.success) { - this.safeSend(ws, { - error: "Invalid message format", - details: result.error.format(), - }); - return; - } - - const data = result.data; - if (data.type === "subscribe") { - this.subscribe(ws, data.table, data.event, data.filter); - return; - } - - this.unsubscribe(ws, data.table, data.event); - } - - handleClose(ws: ServerWebSocket): void { - realtimeLogger.info("Client disconnected"); - - const client = this.clients.get(ws); - if (client) { - // Clean up all subscriptions for this client - for (const [subscriptionKey, subscription] of client.subscriptions.entries()) { - const tableEventKey = `${subscription.table}:${subscription.event}`; - const tableEventSubs = this.tableEventSubscribers.get(tableEventKey); - tableEventSubs?.delete(ws); - if (tableEventSubs && tableEventSubs.size === 0) { - this.tableEventSubscribers.delete(tableEventKey); - } - } - } - - this.clients.delete(ws); - } - - broadcast(table: string, event: RealtimeUpdatePayload["event"], data: unknown): void { - // Server-side filtering: get only subscribers for this specific event type - const subscribers = this.getSubscribersForEvent(table, event); - - if (subscribers.size === 0) { - return; - } - - const payload: RealtimeUpdatePayload = { - type: "update", - table, - event, - data, - timestamp: new Date().toISOString(), - }; - - const message = JSON.stringify(payload); - - const subs = Array.from(subscribers); - for (const ws of subs) { - const client = this.clients.get(ws); - const subscription = client?.subscriptions.get(table); - if (!this.matchesFilter(subscription?.filter, data)) { - continue; - } - - if (!this.safeSend(ws, message)) { - subscribers.delete(ws); - this.handleClose(ws); - } - } - } - - private subscribe( - ws: ServerWebSocket, - table: string, - event: "INSERT" | "UPDATE" | "DELETE" | "*" = "*", - filter?: Record, - ): void { - const client = this.clients.get(ws); - if (!client) { - this.safeSend(ws, { error: "Unauthorized client" }); - ws.close(1008, "Unauthorized"); - return; - } - - if (!this.authorize(client.userId, client.claims, table)) { - realtimeLogger.warn(`Subscription denied for ${client.userId} on ${table}`); - this.safeSend(ws, { error: "Forbidden subscription" }); - return; - } - - // Create subscription key that includes event type - const subscriptionKey = `${table}:${event}`; - const existingSubscription = client.subscriptions.has(subscriptionKey); - if ( - !existingSubscription && - client.subscriptions.size >= this.config.maxSubscriptionsPerClient - ) { - realtimeLogger.warn(`Subscription limit reached for ${client.userId}`); - this.safeSend(ws, { error: "Subscription limit reached" }); - return; - } - - // Track subscribers by table+event for efficient filtering - const tableEventKey = `${table}:${event}`; - const tableEventSet = - this.tableEventSubscribers.get(tableEventKey) ?? new Set>(); - if (!tableEventSet.has(ws) && tableEventSet.size >= this.config.maxSubscribersPerTable) { - realtimeLogger.warn(`Table event subscriber cap reached for ${tableEventKey}`); - this.safeSend(ws, { error: "Table subscription limit reached" }); - return; - } - - client.subscriptions.set(subscriptionKey, { table, event, filter }); - tableEventSet.add(ws); - this.tableEventSubscribers.set(tableEventKey, tableEventSet); - - this.safeSend(ws, { type: "subscribed", table, event, filter }); - realtimeLogger.debug(`Client subscribed to ${table} for ${event} events`); - } - - private unsubscribe( - ws: ServerWebSocket, - table: string, - event: "INSERT" | "UPDATE" | "DELETE" | "*" = "*", - ): void { - const client = this.clients.get(ws); - if (!client) { - return; - } - - // Remove subscription with specific event type - const subscriptionKey = `${table}:${event}`; - client.subscriptions.delete(subscriptionKey); - - // Clean up table+event subscriber tracking - const tableEventKey = `${table}:${event}`; - const tableEventSubs = this.tableEventSubscribers.get(tableEventKey); - tableEventSubs?.delete(ws); - if (tableEventSubs && tableEventSubs.size === 0) { - this.tableEventSubscribers.delete(tableEventKey); - } - - this.safeSend(ws, { type: "unsubscribed", table, event }); - } - - private matchesFilter(filter: Record | undefined, payload: unknown): boolean { - if (!filter || Object.keys(filter).length === 0) { - return true; - } - - if (!payload || typeof payload !== "object") { - return false; - } - - const data = payload as Record; - return Object.entries(filter).every(([key, value]) => deepEqual(data[key], value)); - } - - private safeSend(ws: ServerWebSocket, payload: object | string): boolean { - if (ws.readyState !== WebSocket.OPEN) { - return false; - } - - try { - ws.send(typeof payload === "string" ? payload : JSON.stringify(payload)); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - realtimeLogger.warn(`WebSocket send failed: ${message}`); - return false; - } - } -} - -export const realtime = new RealtimeServer(); +import type { DBEvent } from "@betterbase/shared"; +import type { ServerWebSocket } from "bun"; +import deepEqual from "fast-deep-equal"; +import { z } from "zod"; + +export interface Subscription { + table: string; + event: "INSERT" | "UPDATE" | "DELETE" | "*"; + filter?: Record; +} + +interface Client { + ws: ServerWebSocket; + userId: string; + claims: string[]; + subscriptions: Map; +} + +interface RealtimeUpdatePayload { + type: "update"; + table: string; + event: "INSERT" | "UPDATE" | "DELETE"; + data: unknown; + timestamp: string; +} + +interface RealtimeConfig { + maxClients: number; + maxSubscriptionsPerClient: number; + maxSubscribersPerTable: number; +} + +const messageSchema = z.union([ + z.object({ + type: z.literal("subscribe"), + table: z.string().min(1).max(255), + event: z.enum(["INSERT", "UPDATE", "DELETE", "*"]).default("*"), + filter: z.record(z.string(), z.unknown()).optional(), + }), + z.object({ + type: z.literal("unsubscribe"), + table: z.string().min(1).max(255), + event: z.enum(["INSERT", "UPDATE", "DELETE", "*"]).default("*"), + }), +]); + +const realtimeLogger = { + debug: (message: string): void => console.debug(`[realtime] ${message}`), + info: (message: string): void => console.info(`[realtime] ${message}`), + warn: (message: string): void => console.warn(`[realtime] ${message}`), +}; + +export class RealtimeServer { + private clients = new Map, Client>(); + private tableSubscribers = new Map>>(); + private config: RealtimeConfig; + // CDC event handler for automatic database change events + private cdcCallback: ((event: DBEvent) => void) | null = null; + + // Map to track subscriptions by table+event for efficient filtering + // Key format: "table:event" (e.g., "users:INSERT") + private tableEventSubscribers = new Map>>(); + + constructor(config?: Partial) { + if (process.env.NODE_ENV !== "development" && process.env.ENABLE_DEV_AUTH !== "true") { + realtimeLogger.warn( + "Realtime auth verifier is not configured; dev token parser is disabled. Configure a real verifier for production.", + ); + } + + this.config = { + maxClients: 1000, + maxSubscriptionsPerClient: 50, + maxSubscribersPerTable: 500, + ...config, + }; + } + + /** + * Connect to database change events (CDC) + * This enables automatic event emission when database changes occur + * @param onchange - Callback function that receives DBEvent when data changes + */ + connectCDC(onchange: (event: DBEvent) => void): void { + this.cdcCallback = onchange; + } + + /** + * Handle a database change event from CDC + * This is called automatically when the database emits change events + */ + private handleCDCEvent(event: DBEvent): void { + // Invoke the CDC callback if registered + this.cdcCallback?.(event); + // Broadcast the event to subscribed clients via WebSocket + this.broadcast(event.table, event.type, event.record); + } + + /** + * Process a CDC event and broadcast to WebSocket clients + * Server-side filtering: only delivers to clients with matching subscriptions + */ + processCDCEvent(event: DBEvent): void { + // Invoke the CDC callback if registered + this.cdcCallback?.(event); + // Broadcast to WebSocket clients with server-side filtering + this.broadcast(event.table, event.type, event.record); + } + + /** + * Get subscribers for a specific table and event type + * This enables server-side filtering + */ + private getSubscribersForEvent( + table: string, + event: "INSERT" | "UPDATE" | "DELETE", + ): Set> { + const subscribers = new Set>(); + + // Get exact match subscribers (table + event) + const exactKey = `${table}:${event}`; + const exactSubs = this.tableEventSubscribers.get(exactKey); + if (exactSubs) { + for (const ws of exactSubs) { + subscribers.add(ws); + } + } + + // Get wildcard subscribers (table + *) + const wildcardKey = `${table}:*`; + const wildcardSubs = this.tableEventSubscribers.get(wildcardKey); + if (wildcardSubs) { + for (const ws of wildcardSubs) { + subscribers.add(ws); + } + } + + return subscribers; + } + + authenticate(token: string | undefined): { userId: string; claims: string[] } | null { + if (!token || !token.trim()) return null; + + const allowDevAuth = + process.env.NODE_ENV === "development" || process.env.ENABLE_DEV_AUTH === "true"; + if (!allowDevAuth) { + return null; + } + + const [userId, rawClaims] = token.trim().split(":", 2); + if (!userId) return null; + + const claims = rawClaims + ? rawClaims + .split(",") + .map((claim) => claim.trim()) + .filter(Boolean) + : []; + return { userId, claims }; + } + + authorize(userId: string, claims: string[], table: string): boolean { + return ( + Boolean(userId) && (claims.includes("realtime:*") || claims.includes(`realtime:${table}`)) + ); + } + + handleConnection(ws: ServerWebSocket, token: string | undefined): boolean { + if (this.clients.size >= this.config.maxClients) { + realtimeLogger.warn("Rejecting realtime connection: max clients reached"); + this.safeSend(ws, { error: "Server is busy. Try again later." }); + ws.close(1013, "Server busy"); + return false; + } + + const identity = this.authenticate(token); + if (!identity) { + realtimeLogger.warn("Rejecting unauthenticated realtime connection"); + this.safeSend(ws, { error: "Unauthorized websocket connection" }); + ws.close(1008, "Unauthorized"); + return false; + } + + realtimeLogger.info(`Client connected (${identity.userId})`); + this.clients.set(ws, { + ws, + userId: identity.userId, + claims: identity.claims, + subscriptions: new Map(), + }); + + return true; + } + + handleMessage(ws: ServerWebSocket, rawMessage: string): void { + let parsedJson: unknown; + + try { + parsedJson = JSON.parse(rawMessage); + } catch { + this.safeSend(ws, { error: "Invalid message format" }); + return; + } + + const result = messageSchema.safeParse(parsedJson); + if (!result.success) { + this.safeSend(ws, { + error: "Invalid message format", + details: result.error.format(), + }); + return; + } + + const data = result.data; + if (data.type === "subscribe") { + this.subscribe(ws, data.table, data.event, data.filter); + return; + } + + this.unsubscribe(ws, data.table, data.event); + } + + handleClose(ws: ServerWebSocket): void { + realtimeLogger.info("Client disconnected"); + + const client = this.clients.get(ws); + if (client) { + // Clean up all subscriptions for this client + for (const [subscriptionKey, subscription] of client.subscriptions.entries()) { + const tableEventKey = `${subscription.table}:${subscription.event}`; + const tableEventSubs = this.tableEventSubscribers.get(tableEventKey); + tableEventSubs?.delete(ws); + if (tableEventSubs && tableEventSubs.size === 0) { + this.tableEventSubscribers.delete(tableEventKey); + } + } + } + + this.clients.delete(ws); + } + + broadcast(table: string, event: RealtimeUpdatePayload["event"], data: unknown): void { + // Server-side filtering: get only subscribers for this specific event type + const subscribers = this.getSubscribersForEvent(table, event); + + if (subscribers.size === 0) { + return; + } + + const payload: RealtimeUpdatePayload = { + type: "update", + table, + event, + data, + timestamp: new Date().toISOString(), + }; + + const message = JSON.stringify(payload); + + const subs = Array.from(subscribers); + for (const ws of subs) { + const client = this.clients.get(ws); + const subscription = client?.subscriptions.get(table); + if (!this.matchesFilter(subscription?.filter, data)) { + continue; + } + + if (!this.safeSend(ws, message)) { + subscribers.delete(ws); + this.handleClose(ws); + } + } + } + + private subscribe( + ws: ServerWebSocket, + table: string, + event: "INSERT" | "UPDATE" | "DELETE" | "*" = "*", + filter?: Record, + ): void { + const client = this.clients.get(ws); + if (!client) { + this.safeSend(ws, { error: "Unauthorized client" }); + ws.close(1008, "Unauthorized"); + return; + } + + if (!this.authorize(client.userId, client.claims, table)) { + realtimeLogger.warn(`Subscription denied for ${client.userId} on ${table}`); + this.safeSend(ws, { error: "Forbidden subscription" }); + return; + } + + // Create subscription key that includes event type + const subscriptionKey = `${table}:${event}`; + const existingSubscription = client.subscriptions.has(subscriptionKey); + if ( + !existingSubscription && + client.subscriptions.size >= this.config.maxSubscriptionsPerClient + ) { + realtimeLogger.warn(`Subscription limit reached for ${client.userId}`); + this.safeSend(ws, { error: "Subscription limit reached" }); + return; + } + + // Track subscribers by table+event for efficient filtering + const tableEventKey = `${table}:${event}`; + const tableEventSet = + this.tableEventSubscribers.get(tableEventKey) ?? new Set>(); + if (!tableEventSet.has(ws) && tableEventSet.size >= this.config.maxSubscribersPerTable) { + realtimeLogger.warn(`Table event subscriber cap reached for ${tableEventKey}`); + this.safeSend(ws, { error: "Table subscription limit reached" }); + return; + } + + client.subscriptions.set(subscriptionKey, { table, event, filter }); + tableEventSet.add(ws); + this.tableEventSubscribers.set(tableEventKey, tableEventSet); + + this.safeSend(ws, { type: "subscribed", table, event, filter }); + realtimeLogger.debug(`Client subscribed to ${table} for ${event} events`); + } + + private unsubscribe( + ws: ServerWebSocket, + table: string, + event: "INSERT" | "UPDATE" | "DELETE" | "*" = "*", + ): void { + const client = this.clients.get(ws); + if (!client) { + return; + } + + // Remove subscription with specific event type + const subscriptionKey = `${table}:${event}`; + client.subscriptions.delete(subscriptionKey); + + // Clean up table+event subscriber tracking + const tableEventKey = `${table}:${event}`; + const tableEventSubs = this.tableEventSubscribers.get(tableEventKey); + tableEventSubs?.delete(ws); + if (tableEventSubs && tableEventSubs.size === 0) { + this.tableEventSubscribers.delete(tableEventKey); + } + + this.safeSend(ws, { type: "unsubscribed", table, event }); + } + + private matchesFilter(filter: Record | undefined, payload: unknown): boolean { + if (!filter || Object.keys(filter).length === 0) { + return true; + } + + if (!payload || typeof payload !== "object") { + return false; + } + + const data = payload as Record; + return Object.entries(filter).every(([key, value]) => deepEqual(data[key], value)); + } + + private safeSend(ws: ServerWebSocket, payload: object | string): boolean { + if (ws.readyState !== WebSocket.OPEN) { + return false; + } + + try { + ws.send(typeof payload === "string" ? payload : JSON.stringify(payload)); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + realtimeLogger.warn(`WebSocket send failed: ${message}`); + return false; + } + } +} + +export const realtime = new RealtimeServer(); diff --git a/templates/base/src/middleware/auth.ts b/templates/base/src/middleware/auth.ts index 2584e88..08951cd 100644 --- a/templates/base/src/middleware/auth.ts +++ b/templates/base/src/middleware/auth.ts @@ -1,25 +1,25 @@ -import type { Context, Next } from "hono"; -import { auth } from "../auth"; - -export async function requireAuth(c: Context, next: Next) { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }); - if (!session) { - return c.json({ data: null, error: "Unauthorized" }, 401); - } - c.set("user", session.user); - c.set("session", session.session); - await next(); -} - -export async function optionalAuth(c: Context, next: Next) { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }); - if (session) { - c.set("user", session.user); - c.set("session", session.session); - } - await next(); -} +import type { Context, Next } from "hono"; +import { auth } from "../auth"; + +export async function requireAuth(c: Context, next: Next) { + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + if (!session) { + return c.json({ data: null, error: "Unauthorized" }, 401); + } + c.set("user", session.user); + c.set("session", session.session); + await next(); +} + +export async function optionalAuth(c: Context, next: Next) { + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + if (session) { + c.set("user", session.user); + c.set("session", session.session); + } + await next(); +} diff --git a/templates/base/src/middleware/validation.ts b/templates/base/src/middleware/validation.ts index 9a3053a..ddcce8b 100644 --- a/templates/base/src/middleware/validation.ts +++ b/templates/base/src/middleware/validation.ts @@ -1,21 +1,21 @@ -import { HTTPException } from "hono/http-exception"; -import type { ZodType } from "zod"; - -export function parseBody(schema: ZodType, body: unknown): T { - const result = schema.safeParse(body); - - if (!result.success) { - throw new HTTPException(400, { - message: "Validation failed", - cause: { - errors: result.error.issues.map((issue) => ({ - path: issue.path.join("."), - message: issue.message, - code: issue.code, - })), - }, - }); - } - - return result.data; -} +import { HTTPException } from "hono/http-exception"; +import type { ZodType } from "zod"; + +export function parseBody(schema: ZodType, body: unknown): T { + const result = schema.safeParse(body); + + if (!result.success) { + throw new HTTPException(400, { + message: "Validation failed", + cause: { + errors: result.error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + }, + }); + } + + return result.data; +} diff --git a/templates/base/src/routes/graphql.d.ts b/templates/base/src/routes/graphql.d.ts index 84493d1..74938df 100644 --- a/templates/base/src/routes/graphql.d.ts +++ b/templates/base/src/routes/graphql.d.ts @@ -1,9 +1,9 @@ -/** - * Type declarations for dynamically generated GraphQL route - */ - -import type { Hono } from "hono"; - -declare module "./routes/graphql" { - export const graphqlRoute: Hono; -} +/** + * Type declarations for dynamically generated GraphQL route + */ + +import type { Hono } from "hono"; + +declare module "./routes/graphql" { + export const graphqlRoute: Hono; +} diff --git a/templates/base/src/routes/health.ts b/templates/base/src/routes/health.ts index fc282a3..3a7d7e2 100644 --- a/templates/base/src/routes/health.ts +++ b/templates/base/src/routes/health.ts @@ -1,26 +1,26 @@ -import { sql } from "drizzle-orm"; -import { Hono } from "hono"; -import { db } from "../db"; - -export const healthRoute = new Hono(); - -healthRoute.get("/", async (c) => { - try { - await db.run(sql`select 1`); - - return c.json({ - status: "healthy", - database: "connected", - timestamp: new Date().toISOString(), - }); - } catch { - return c.json( - { - status: "unhealthy", - database: "disconnected", - timestamp: new Date().toISOString(), - }, - 503, - ); - } -}); +import { sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { db } from "../db"; + +export const healthRoute = new Hono(); + +healthRoute.get("/", async (c) => { + try { + await db.run(sql`select 1`); + + return c.json({ + status: "healthy", + database: "connected", + timestamp: new Date().toISOString(), + }); + } catch { + return c.json( + { + status: "unhealthy", + database: "disconnected", + timestamp: new Date().toISOString(), + }, + 503, + ); + } +}); diff --git a/templates/base/src/routes/index.ts b/templates/base/src/routes/index.ts index cfa4604..b418442 100644 --- a/templates/base/src/routes/index.ts +++ b/templates/base/src/routes/index.ts @@ -1,31 +1,31 @@ -import type { Hono } from "hono"; -import { cors } from "hono/cors"; -import { HTTPException } from "hono/http-exception"; -import { logger } from "hono/logger"; -import { env } from "../lib/env"; -import { healthRoute } from "./health"; -import { storageRouter } from "./storage"; -import { usersRoute } from "./users"; - -export function registerRoutes(app: Hono): void { - app.use("*", cors()); - app.use("*", logger()); - - app.onError((err, c) => { - const isHttpError = err instanceof HTTPException; - const showDetailedError = env.NODE_ENV === "development" || isHttpError; - - return c.json( - { - error: showDetailedError ? err.message : "Internal Server Error", - stack: env.NODE_ENV === "development" ? err.stack : undefined, - details: isHttpError ? ((err as { cause?: unknown }).cause ?? null) : null, - }, - isHttpError ? err.status : 500, - ); - }); - - app.route("/health", healthRoute); - app.route("/api/users", usersRoute); - app.route("/api/storage", storageRouter); -} +import type { Hono } from "hono"; +import { cors } from "hono/cors"; +import { HTTPException } from "hono/http-exception"; +import { logger } from "hono/logger"; +import { env } from "../lib/env"; +import { healthRoute } from "./health"; +import { storageRouter } from "./storage"; +import { usersRoute } from "./users"; + +export function registerRoutes(app: Hono): void { + app.use("*", cors()); + app.use("*", logger()); + + app.onError((err, c) => { + const isHttpError = err instanceof HTTPException; + const showDetailedError = env.NODE_ENV === "development" || isHttpError; + + return c.json( + { + error: showDetailedError ? err.message : "Internal Server Error", + stack: env.NODE_ENV === "development" ? err.stack : undefined, + details: isHttpError ? ((err as { cause?: unknown }).cause ?? null) : null, + }, + isHttpError ? err.status : 500, + ); + }); + + app.route("/health", healthRoute); + app.route("/api/users", usersRoute); + app.route("/api/storage", storageRouter); +} diff --git a/templates/base/src/routes/storage.ts b/templates/base/src/routes/storage.ts index f9e4d36..bf2eb42 100644 --- a/templates/base/src/routes/storage.ts +++ b/templates/base/src/routes/storage.ts @@ -1,524 +1,524 @@ -import { - type StorageConfig, - type StorageFactory, - type StoragePolicy, - checkStorageAccess, - createStorage, - getPolicyDenialMessage, -} from "@betterbase/core/storage"; -import type { Context, Next } from "hono"; -import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { ZodError, z } from "zod"; -import { auth } from "../auth"; -import { parseBody } from "../middleware/validation"; - -// Type for user from auth -type AuthUser = { id: string; [key: string]: unknown }; - -// Extended context type for storage operations -interface StorageContext extends Context { - get(key: "user"): AuthUser | undefined; - get(key: "session"): unknown; -} - -// Default max file size: 50MB -const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; - -// Get storage config from environment variables -function getStorageConfig(): StorageConfig | null { - const provider = process.env.STORAGE_PROVIDER; - const bucket = process.env.STORAGE_BUCKET; - - if (!provider || !bucket) { - return null; - } - - switch (provider) { - case "s3": - return { - provider: "s3" as const, - bucket, - region: process.env.STORAGE_REGION || "us-east-1", - accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", - secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", - }; - case "r2": - return { - provider: "r2" as const, - bucket, - accountId: process.env.STORAGE_ACCOUNT_ID || "", - accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", - secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", - endpoint: process.env.STORAGE_ENDPOINT, - }; - case "backblaze": - return { - provider: "backblaze" as const, - bucket, - region: process.env.STORAGE_REGION || "us-west-002", - accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", - secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", - endpoint: process.env.STORAGE_ENDPOINT, - }; - case "minio": - return { - provider: "minio" as const, - bucket, - endpoint: process.env.STORAGE_ENDPOINT || "localhost:9000", - port: Number.parseInt(process.env.STORAGE_PORT || "9000", 10), - useSSL: process.env.STORAGE_USE_SSL === "true", - accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", - secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", - }; - default: - return null; - } -} - -// Get storage policies from environment variables -function getStoragePolicies(): StoragePolicy[] { - const policiesJson = process.env.STORAGE_POLICIES; - if (!policiesJson) { - return []; - } - - try { - const parsed = JSON.parse(policiesJson); - if (Array.isArray(parsed)) { - return parsed; - } - return []; - } catch { - console.warn("[Storage] Invalid STORAGE_POLICIES JSON, ignoring"); - return []; - } -} - -// Initialize storage factory -const storageConfig = getStorageConfig(); -const storage: StorageFactory | null = storageConfig ? createStorage(storageConfig) : null; -const storagePolicies = getStoragePolicies(); - -// Validate bucket access - only allow configured bucket -function validateBucket(bucket: string): void { - if (!storageConfig) { - throw new HTTPException(503, { message: "Storage not configured" }); - } - if (bucket !== storageConfig.bucket) { - throw new HTTPException(403, { message: "Invalid bucket access" }); - } -} - -// Get allowed MIME types from environment -function getAllowedMimeTypes(): string[] { - const allowed = process.env.STORAGE_ALLOWED_MIME_TYPES; - if (!allowed) { - return []; // No restrictions - } - return allowed.split(",").map((m) => m.trim()); -} - -// Get max file size from environment -function getMaxFileSize(): number { - const maxSize = process.env.STORAGE_MAX_FILE_SIZE; - if (!maxSize) { - return DEFAULT_MAX_FILE_SIZE; - } - const parsed = Number.parseInt(maxSize, 10); - return Number.isNaN(parsed) ? DEFAULT_MAX_FILE_SIZE : parsed; -} - -// Validate MIME type for upload -function validateMimeType(contentType: string): void { - const allowedTypes = getAllowedMimeTypes(); - if (allowedTypes.length === 0) { - return; // No restrictions - } - - // Handle wildcards - const normalizedType = contentType.toLowerCase(); - const typePart = normalizedType.split("/")[0]; - - for (const allowed of allowedTypes) { - if (allowed === normalizedType) { - return; // Exact match - } - if (allowed.endsWith("/*")) { - const prefix = allowed.slice(0, -1); - if (normalizedType.startsWith(prefix)) { - return; // Wildcard match (e.g., "image/*") - } - } - } - - throw new HTTPException(403, { - message: `MIME type "${contentType}" is not allowed. Allowed types: ${allowedTypes.join(", ")}`, - }); -} - -// Validate file size -function validateFileSize(size: number): void { - const maxSize = getMaxFileSize(); - if (size > maxSize) { - const maxSizeMB = Math.round(maxSize / (1024 * 1024)); - throw new HTTPException(400, { - message: `File too large. Maximum size is ${maxSizeMB}MB`, - }); - } -} - -// Check storage policy for an operation -function checkPolicy( - operation: "upload" | "download" | "list" | "delete", - userId: string | null, - bucket: string, - path: string, -): void { - // Fail-closed: if no policies are configured, deny by default - if (storagePolicies.length === 0) { - console.log(`[Storage Policy] No policies configured, denying ${operation} on ${path}`); - throw new HTTPException(403, { message: getPolicyDenialMessage(operation, path) }); - } - - const allowed = checkStorageAccess(storagePolicies, userId, bucket, operation, path); - if (!allowed) { - throw new HTTPException(403, { message: getPolicyDenialMessage(operation, path) }); - } -} - -// Sanitize path to prevent path traversal attacks -function sanitizePath(path: string): string { - // Remove leading slashes and normalize - const sanitized = path.replace(/^\/+/, "").replace(/\/+/g, "/"); - - // Check for path traversal attempts - if (sanitized.includes("..") || sanitized.startsWith("/")) { - throw new HTTPException(400, { - message: "Invalid path: path traversal not allowed", - }); - } - - return sanitized; -} - -// Validate and sanitize path parameter -function validatePath(path: string): string { - if (!path || path.length === 0) { - throw new HTTPException(400, { message: "Path is required" }); - } - return sanitizePath(path); -} - -// Auth middleware for storage routes -async function requireAuth(c: Context, next: Next): Promise { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }); - if (!session) { - return c.json({ error: "Unauthorized" }, 401); - } - c.set("user", session.user); - c.set("session", session.session); - await next(); -} - -// Schemas for request validation -const signUrlSchema = z.object({ - expiresIn: z.number().int().positive().optional().default(3600), -}); - -const deleteFilesSchema = z.object({ - paths: z.array(z.string().min(1)).min(1), -}); - -export const storageRouter = new Hono(); - -// Apply auth middleware to all storage routes (except public URL) -storageRouter.use("/*", async (c, next) => { - // Skip auth for public URL endpoint - if (c.req.path.toString().endsWith("/public")) { - await next(); - return; - } - await requireAuth(c, next); -}); - -// GET /api/storage/:bucket - List files -storageRouter.get("/:bucket", async (c: StorageContext) => { - try { - const bucket = c.req.param("bucket"); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - // Check list policy (allow public access if policy is 'true') - const user = c.get("user") as AuthUser | undefined; - const userId = user?.id || null; - const prefix = c.req.query("prefix") || ""; - checkPolicy("list", userId, bucket, prefix); - - const sanitizedPrefix = prefix ? sanitizePath(prefix) : undefined; - const result = await storage.from(bucket).list(sanitizedPrefix); - - if (result.error) { - return c.json({ error: result.error.message }, 500); - } - - const files = (result.data || []).map((obj) => ({ - name: obj.key, - size: obj.size, - lastModified: obj.lastModified.toISOString(), - })); - - return c.json({ files }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - console.error("Failed to list files:", error); - return c.json({ error: "Failed to list files" }, 500); - } -}); - -// DELETE /api/storage/:bucket - Delete files -storageRouter.delete("/:bucket", async (c: StorageContext) => { - try { - const bucket = c.req.param("bucket"); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - const user = c.get("user") as AuthUser | undefined; - if (!user) { - return c.json({ error: "Unauthorized" }, 401); - } - - const body = await c.req.json().catch(() => ({})); - const parsed = parseBody(deleteFilesSchema, body); - - // Validate all paths and check delete policy - for (const p of parsed.paths) { - const sanitizedPath = validatePath(p); - checkPolicy("delete", user.id, bucket, sanitizedPath); - } - - const sanitizedPaths = parsed.paths.map((p: string) => validatePath(p)); - - const result = await storage.from(bucket).remove(sanitizedPaths); - - if (result.error) { - return c.json({ error: result.error.message }, 500); - } - - return c.json({ - message: result.data?.message || "Files deleted successfully", - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - if (error instanceof ZodError) { - return c.json( - { - error: "Invalid request body", - details: error.issues, - }, - 400, - ); - } - console.error("Failed to delete files:", error); - return c.json({ error: "Failed to delete files" }, 500); - } -}); - -// POST /api/storage/:bucket/upload - Upload a file -storageRouter.post("/:bucket/upload", async (c: StorageContext) => { - try { - const bucket = c.req.param("bucket"); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - const user = c.get("user") as AuthUser | undefined; - if (!user) { - return c.json({ error: "Unauthorized" }, 401); - } - - // Get content type from headers - const contentType = c.req.header("Content-Type") || "application/octet-stream"; - - // Validate MIME type - validateMimeType(contentType); - - const contentLength = c.req.header("Content-Length"); - - // Get the file buffer - const arrayBuffer = await c.req.arrayBuffer(); - const body = Buffer.from(arrayBuffer); - - // Validate file size - validateFileSize(body.length); - - // Extract and validate path from query param or use default - const pathInput = c.req.query("path") || `uploads/${Date.now()}-file`; - const path = validatePath(pathInput); - - // Check upload policy before uploading - checkPolicy("upload", user.id, bucket, path); - - const result = await storage.from(bucket).upload(path, body, { - contentType, - }); - - if (result.error) { - return c.json({ error: result.error.message }, 500); - } - - const publicUrl = storage.from(bucket).getPublicUrl(path); - - return c.json({ - path, - url: publicUrl, - size: result.data?.size || 0, - contentType: result.data?.contentType || contentType, - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - console.error("Failed to upload file:", error); - return c.json({ error: "Failed to upload file" }, 500); - } -}); - -// GET /api/storage/:bucket/:key - Download a file -storageRouter.get("/:bucket/:key", async (c: StorageContext) => { - try { - const bucket = c.req.param("bucket"); - const keyInput = c.req.param("key"); - const key = validatePath(keyInput); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - // Check download policy - const user = c.get("user") as AuthUser | undefined; - const userId = user?.id || null; - checkPolicy("download", userId, bucket, key); - - const result = await storage.from(bucket).download(key); - - if (result.error) { - if (result.error.message.includes("NoSuchKey") || result.error.message.includes("NotFound")) { - return c.json({ error: "File not found" }, 404); - } - return c.json({ error: result.error.message }, 500); - } - - if (!result.data) { - return c.json({ error: "File not found" }, 404); - } - - // Get content type from result metadata or use default - const contentType = "application/octet-stream"; - - return c.body(new Uint8Array(result.data), { - headers: { - "Content-Type": contentType, - "Content-Length": String(result.data?.length || 0), - "Content-Disposition": `attachment; filename="${key.split("/").pop()}"`, - }, - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - console.error("Failed to download file:", error); - return c.json({ error: "Failed to download file" }, 500); - } -}); - -// GET /api/storage/:bucket/:key/public - Get public URL -storageRouter.get("/:bucket/:key/public", async (c: StorageContext) => { - try { - const bucket = c.req.param("bucket"); - const keyInput = c.req.param("key"); - const key = validatePath(keyInput); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - // Check download policy (allows anonymous if policy is 'true') - const user = c.get("user") as AuthUser | undefined; - const userId = user?.id || null; - checkPolicy("download", userId, bucket, key); - - const publicUrl = storage.from(bucket).getPublicUrl(key); - - return c.json({ publicUrl }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - console.error("Failed to get public URL:", error); - return c.json({ error: "Failed to get public URL" }, 500); - } -}); - -// POST /api/storage/:bucket/:key/sign - Create signed URL -storageRouter.post("/:bucket/:key/sign", async (c: StorageContext) => { - try { - const bucket = c.req.param("bucket"); - const keyInput = c.req.param("key"); - const key = validatePath(keyInput); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - // Check download policy for signing - const user = c.get("user") as AuthUser | undefined; - const userId = user?.id || null; - checkPolicy("download", userId, bucket, key); - - const body = await c.req.json().catch(() => ({})); - const parsed = parseBody(signUrlSchema, body); - - const result = await storage.from(bucket).createSignedUrl(key, { - expiresIn: parsed.expiresIn, - }); - - if (result.error) { - return c.json({ error: result.error.message }, 500); - } - - return c.json({ signedUrl: result.data?.signedUrl || "" }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - if (error instanceof ZodError) { - return c.json( - { - error: "Invalid request body", - details: error.issues, - }, - 400, - ); - } - console.error("Failed to create signed URL:", error); - return c.json({ error: "Failed to create signed URL" }, 500); - } -}); +import { + type StorageConfig, + type StorageFactory, + type StoragePolicy, + checkStorageAccess, + createStorage, + getPolicyDenialMessage, +} from "@betterbase/core/storage"; +import type { Context, Next } from "hono"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { ZodError, z } from "zod"; +import { auth } from "../auth"; +import { parseBody } from "../middleware/validation"; + +// Type for user from auth +type AuthUser = { id: string; [key: string]: unknown }; + +// Extended context type for storage operations +interface StorageContext extends Context { + get(key: "user"): AuthUser | undefined; + get(key: "session"): unknown; +} + +// Default max file size: 50MB +const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; + +// Get storage config from environment variables +function getStorageConfig(): StorageConfig | null { + const provider = process.env.STORAGE_PROVIDER; + const bucket = process.env.STORAGE_BUCKET; + + if (!provider || !bucket) { + return null; + } + + switch (provider) { + case "s3": + return { + provider: "s3" as const, + bucket, + region: process.env.STORAGE_REGION || "us-east-1", + accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", + secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", + }; + case "r2": + return { + provider: "r2" as const, + bucket, + accountId: process.env.STORAGE_ACCOUNT_ID || "", + accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", + secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", + endpoint: process.env.STORAGE_ENDPOINT, + }; + case "backblaze": + return { + provider: "backblaze" as const, + bucket, + region: process.env.STORAGE_REGION || "us-west-002", + accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", + secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", + endpoint: process.env.STORAGE_ENDPOINT, + }; + case "minio": + return { + provider: "minio" as const, + bucket, + endpoint: process.env.STORAGE_ENDPOINT || "localhost:9000", + port: Number.parseInt(process.env.STORAGE_PORT || "9000", 10), + useSSL: process.env.STORAGE_USE_SSL === "true", + accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", + secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", + }; + default: + return null; + } +} + +// Get storage policies from environment variables +function getStoragePolicies(): StoragePolicy[] { + const policiesJson = process.env.STORAGE_POLICIES; + if (!policiesJson) { + return []; + } + + try { + const parsed = JSON.parse(policiesJson); + if (Array.isArray(parsed)) { + return parsed; + } + return []; + } catch { + console.warn("[Storage] Invalid STORAGE_POLICIES JSON, ignoring"); + return []; + } +} + +// Initialize storage factory +const storageConfig = getStorageConfig(); +const storage: StorageFactory | null = storageConfig ? createStorage(storageConfig) : null; +const storagePolicies = getStoragePolicies(); + +// Validate bucket access - only allow configured bucket +function validateBucket(bucket: string): void { + if (!storageConfig) { + throw new HTTPException(503, { message: "Storage not configured" }); + } + if (bucket !== storageConfig.bucket) { + throw new HTTPException(403, { message: "Invalid bucket access" }); + } +} + +// Get allowed MIME types from environment +function getAllowedMimeTypes(): string[] { + const allowed = process.env.STORAGE_ALLOWED_MIME_TYPES; + if (!allowed) { + return []; // No restrictions + } + return allowed.split(",").map((m) => m.trim()); +} + +// Get max file size from environment +function getMaxFileSize(): number { + const maxSize = process.env.STORAGE_MAX_FILE_SIZE; + if (!maxSize) { + return DEFAULT_MAX_FILE_SIZE; + } + const parsed = Number.parseInt(maxSize, 10); + return Number.isNaN(parsed) ? DEFAULT_MAX_FILE_SIZE : parsed; +} + +// Validate MIME type for upload +function validateMimeType(contentType: string): void { + const allowedTypes = getAllowedMimeTypes(); + if (allowedTypes.length === 0) { + return; // No restrictions + } + + // Handle wildcards + const normalizedType = contentType.toLowerCase(); + const typePart = normalizedType.split("/")[0]; + + for (const allowed of allowedTypes) { + if (allowed === normalizedType) { + return; // Exact match + } + if (allowed.endsWith("/*")) { + const prefix = allowed.slice(0, -1); + if (normalizedType.startsWith(prefix)) { + return; // Wildcard match (e.g., "image/*") + } + } + } + + throw new HTTPException(403, { + message: `MIME type "${contentType}" is not allowed. Allowed types: ${allowedTypes.join(", ")}`, + }); +} + +// Validate file size +function validateFileSize(size: number): void { + const maxSize = getMaxFileSize(); + if (size > maxSize) { + const maxSizeMB = Math.round(maxSize / (1024 * 1024)); + throw new HTTPException(400, { + message: `File too large. Maximum size is ${maxSizeMB}MB`, + }); + } +} + +// Check storage policy for an operation +function checkPolicy( + operation: "upload" | "download" | "list" | "delete", + userId: string | null, + bucket: string, + path: string, +): void { + // Fail-closed: if no policies are configured, deny by default + if (storagePolicies.length === 0) { + console.log(`[Storage Policy] No policies configured, denying ${operation} on ${path}`); + throw new HTTPException(403, { message: getPolicyDenialMessage(operation, path) }); + } + + const allowed = checkStorageAccess(storagePolicies, userId, bucket, operation, path); + if (!allowed) { + throw new HTTPException(403, { message: getPolicyDenialMessage(operation, path) }); + } +} + +// Sanitize path to prevent path traversal attacks +function sanitizePath(path: string): string { + // Remove leading slashes and normalize + const sanitized = path.replace(/^\/+/, "").replace(/\/+/g, "/"); + + // Check for path traversal attempts + if (sanitized.includes("..") || sanitized.startsWith("/")) { + throw new HTTPException(400, { + message: "Invalid path: path traversal not allowed", + }); + } + + return sanitized; +} + +// Validate and sanitize path parameter +function validatePath(path: string): string { + if (!path || path.length === 0) { + throw new HTTPException(400, { message: "Path is required" }); + } + return sanitizePath(path); +} + +// Auth middleware for storage routes +async function requireAuth(c: Context, next: Next): Promise { + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + c.set("user", session.user); + c.set("session", session.session); + await next(); +} + +// Schemas for request validation +const signUrlSchema = z.object({ + expiresIn: z.number().int().positive().optional().default(3600), +}); + +const deleteFilesSchema = z.object({ + paths: z.array(z.string().min(1)).min(1), +}); + +export const storageRouter = new Hono(); + +// Apply auth middleware to all storage routes (except public URL) +storageRouter.use("/*", async (c, next) => { + // Skip auth for public URL endpoint + if (c.req.path.toString().endsWith("/public")) { + await next(); + return; + } + await requireAuth(c, next); +}); + +// GET /api/storage/:bucket - List files +storageRouter.get("/:bucket", async (c: StorageContext) => { + try { + const bucket = c.req.param("bucket"); + validateBucket(bucket); + + if (!storage) { + return c.json({ error: "Storage not configured" }, 503); + } + + // Check list policy (allow public access if policy is 'true') + const user = c.get("user") as AuthUser | undefined; + const userId = user?.id || null; + const prefix = c.req.query("prefix") || ""; + checkPolicy("list", userId, bucket, prefix); + + const sanitizedPrefix = prefix ? sanitizePath(prefix) : undefined; + const result = await storage.from(bucket).list(sanitizedPrefix); + + if (result.error) { + return c.json({ error: result.error.message }, 500); + } + + const files = (result.data || []).map((obj) => ({ + name: obj.key, + size: obj.size, + lastModified: obj.lastModified.toISOString(), + })); + + return c.json({ files }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + console.error("Failed to list files:", error); + return c.json({ error: "Failed to list files" }, 500); + } +}); + +// DELETE /api/storage/:bucket - Delete files +storageRouter.delete("/:bucket", async (c: StorageContext) => { + try { + const bucket = c.req.param("bucket"); + validateBucket(bucket); + + if (!storage) { + return c.json({ error: "Storage not configured" }, 503); + } + + const user = c.get("user") as AuthUser | undefined; + if (!user) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = await c.req.json().catch(() => ({})); + const parsed = parseBody(deleteFilesSchema, body); + + // Validate all paths and check delete policy + for (const p of parsed.paths) { + const sanitizedPath = validatePath(p); + checkPolicy("delete", user.id, bucket, sanitizedPath); + } + + const sanitizedPaths = parsed.paths.map((p: string) => validatePath(p)); + + const result = await storage.from(bucket).remove(sanitizedPaths); + + if (result.error) { + return c.json({ error: result.error.message }, 500); + } + + return c.json({ + message: result.data?.message || "Files deleted successfully", + }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + if (error instanceof ZodError) { + return c.json( + { + error: "Invalid request body", + details: error.issues, + }, + 400, + ); + } + console.error("Failed to delete files:", error); + return c.json({ error: "Failed to delete files" }, 500); + } +}); + +// POST /api/storage/:bucket/upload - Upload a file +storageRouter.post("/:bucket/upload", async (c: StorageContext) => { + try { + const bucket = c.req.param("bucket"); + validateBucket(bucket); + + if (!storage) { + return c.json({ error: "Storage not configured" }, 503); + } + + const user = c.get("user") as AuthUser | undefined; + if (!user) { + return c.json({ error: "Unauthorized" }, 401); + } + + // Get content type from headers + const contentType = c.req.header("Content-Type") || "application/octet-stream"; + + // Validate MIME type + validateMimeType(contentType); + + const contentLength = c.req.header("Content-Length"); + + // Get the file buffer + const arrayBuffer = await c.req.arrayBuffer(); + const body = Buffer.from(arrayBuffer); + + // Validate file size + validateFileSize(body.length); + + // Extract and validate path from query param or use default + const pathInput = c.req.query("path") || `uploads/${Date.now()}-file`; + const path = validatePath(pathInput); + + // Check upload policy before uploading + checkPolicy("upload", user.id, bucket, path); + + const result = await storage.from(bucket).upload(path, body, { + contentType, + }); + + if (result.error) { + return c.json({ error: result.error.message }, 500); + } + + const publicUrl = storage.from(bucket).getPublicUrl(path); + + return c.json({ + path, + url: publicUrl, + size: result.data?.size || 0, + contentType: result.data?.contentType || contentType, + }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + console.error("Failed to upload file:", error); + return c.json({ error: "Failed to upload file" }, 500); + } +}); + +// GET /api/storage/:bucket/:key - Download a file +storageRouter.get("/:bucket/:key", async (c: StorageContext) => { + try { + const bucket = c.req.param("bucket"); + const keyInput = c.req.param("key"); + const key = validatePath(keyInput); + validateBucket(bucket); + + if (!storage) { + return c.json({ error: "Storage not configured" }, 503); + } + + // Check download policy + const user = c.get("user") as AuthUser | undefined; + const userId = user?.id || null; + checkPolicy("download", userId, bucket, key); + + const result = await storage.from(bucket).download(key); + + if (result.error) { + if (result.error.message.includes("NoSuchKey") || result.error.message.includes("NotFound")) { + return c.json({ error: "File not found" }, 404); + } + return c.json({ error: result.error.message }, 500); + } + + if (!result.data) { + return c.json({ error: "File not found" }, 404); + } + + // Get content type from result metadata or use default + const contentType = "application/octet-stream"; + + return c.body(new Uint8Array(result.data), { + headers: { + "Content-Type": contentType, + "Content-Length": String(result.data?.length || 0), + "Content-Disposition": `attachment; filename="${key.split("/").pop()}"`, + }, + }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + console.error("Failed to download file:", error); + return c.json({ error: "Failed to download file" }, 500); + } +}); + +// GET /api/storage/:bucket/:key/public - Get public URL +storageRouter.get("/:bucket/:key/public", async (c: StorageContext) => { + try { + const bucket = c.req.param("bucket"); + const keyInput = c.req.param("key"); + const key = validatePath(keyInput); + validateBucket(bucket); + + if (!storage) { + return c.json({ error: "Storage not configured" }, 503); + } + + // Check download policy (allows anonymous if policy is 'true') + const user = c.get("user") as AuthUser | undefined; + const userId = user?.id || null; + checkPolicy("download", userId, bucket, key); + + const publicUrl = storage.from(bucket).getPublicUrl(key); + + return c.json({ publicUrl }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + console.error("Failed to get public URL:", error); + return c.json({ error: "Failed to get public URL" }, 500); + } +}); + +// POST /api/storage/:bucket/:key/sign - Create signed URL +storageRouter.post("/:bucket/:key/sign", async (c: StorageContext) => { + try { + const bucket = c.req.param("bucket"); + const keyInput = c.req.param("key"); + const key = validatePath(keyInput); + validateBucket(bucket); + + if (!storage) { + return c.json({ error: "Storage not configured" }, 503); + } + + // Check download policy for signing + const user = c.get("user") as AuthUser | undefined; + const userId = user?.id || null; + checkPolicy("download", userId, bucket, key); + + const body = await c.req.json().catch(() => ({})); + const parsed = parseBody(signUrlSchema, body); + + const result = await storage.from(bucket).createSignedUrl(key, { + expiresIn: parsed.expiresIn, + }); + + if (result.error) { + return c.json({ error: result.error.message }, 500); + } + + return c.json({ signedUrl: result.data?.signedUrl || "" }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + if (error instanceof ZodError) { + return c.json( + { + error: "Invalid request body", + details: error.issues, + }, + 400, + ); + } + console.error("Failed to create signed URL:", error); + return c.json({ error: "Failed to create signed URL" }, 500); + } +}); diff --git a/templates/base/src/routes/users.ts b/templates/base/src/routes/users.ts index b0d58d0..68645c6 100644 --- a/templates/base/src/routes/users.ts +++ b/templates/base/src/routes/users.ts @@ -1,107 +1,107 @@ -//templates/base/src/routes/users.ts - -import { asc } from "drizzle-orm"; -import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { ZodError, z } from "zod"; -import { db } from "../db"; -import { users } from "../db/schema"; -import { parseBody } from "../middleware/validation"; - -export const createUserSchema = z.object({ - email: z.string().email(), - name: z.string().min(1), -}); - -const DEFAULT_LIMIT = 25; -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), -}); - -export const usersRoute = new Hono(); - -usersRoute.get("/", async (c) => { - try { - const pagination = paginationSchema.parse({ - limit: c.req.query("limit"), - offset: c.req.query("offset"), - }); - - const limit = Math.min(pagination.limit, MAX_LIMIT); - const offset = pagination.offset; - - if (limit === 0) { - return c.json({ - users: [], - pagination: { - limit, - offset, - // No DB query is run for limit=0, so hasMore cannot be determined. - hasMore: null, - }, - }); - } - - const rows = await db - .select() - .from(users) - .orderBy(asc(users.id)) - .limit(limit + 1) - .offset(offset); - const hasMore = rows.length > limit; - const paginatedUsers = rows.slice(0, limit); - - return c.json({ - users: paginatedUsers, - pagination: { - limit, - offset, - hasMore, - }, - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - - if (error instanceof ZodError) { - return c.json( - { - error: "Invalid pagination query parameters", - details: error.issues, - }, - 400, - ); - } - - console.error("Failed to fetch users:", error); - throw error; - } -}); - -usersRoute.post("/", async (c) => { - try { - const body = await c.req.json(); - const parsed = parseBody(createUserSchema, body); - - // TODO: persist parsed user via db.insert(users) or a dedicated UsersService. - return c.json({ - message: "User payload validated (not persisted)", - user: parsed, - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - - if (error instanceof SyntaxError) { - throw new HTTPException(400, { message: "Malformed JSON body" }); - } - - throw error; - } -}); +//templates/base/src/routes/users.ts + +import { asc } from "drizzle-orm"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { ZodError, z } from "zod"; +import { db } from "../db"; +import { users } from "../db/schema"; +import { parseBody } from "../middleware/validation"; + +export const createUserSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), +}); + +const DEFAULT_LIMIT = 25; +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), +}); + +export const usersRoute = new Hono(); + +usersRoute.get("/", async (c) => { + try { + const pagination = paginationSchema.parse({ + limit: c.req.query("limit"), + offset: c.req.query("offset"), + }); + + const limit = Math.min(pagination.limit, MAX_LIMIT); + const offset = pagination.offset; + + if (limit === 0) { + return c.json({ + users: [], + pagination: { + limit, + offset, + // No DB query is run for limit=0, so hasMore cannot be determined. + hasMore: null, + }, + }); + } + + const rows = await db + .select() + .from(users) + .orderBy(asc(users.id)) + .limit(limit + 1) + .offset(offset); + const hasMore = rows.length > limit; + const paginatedUsers = rows.slice(0, limit); + + return c.json({ + users: paginatedUsers, + pagination: { + limit, + offset, + hasMore, + }, + }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + + if (error instanceof ZodError) { + return c.json( + { + error: "Invalid pagination query parameters", + details: error.issues, + }, + 400, + ); + } + + console.error("Failed to fetch users:", error); + throw error; + } +}); + +usersRoute.post("/", async (c) => { + try { + const body = await c.req.json(); + const parsed = parseBody(createUserSchema, body); + + // TODO: persist parsed user via db.insert(users) or a dedicated UsersService. + return c.json({ + message: "User payload validated (not persisted)", + user: parsed, + }); + } catch (error) { + if (error instanceof HTTPException) { + throw error; + } + + if (error instanceof SyntaxError) { + throw new HTTPException(400, { message: "Malformed JSON body" }); + } + + throw error; + } +}); diff --git a/templates/base/test/crud.test.ts b/templates/base/test/crud.test.ts index 47e5a18..2066dd8 100644 --- a/templates/base/test/crud.test.ts +++ b/templates/base/test/crud.test.ts @@ -1,106 +1,106 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { registerRoutes } from "../src/routes"; - -describe("users CRUD endpoint", () => { - let app: Hono; - - beforeAll(async () => { - // Import db AFTER app modules load — this is the exact same - // db instance the route handlers will use at runtime. - // We run CREATE TABLE IF NOT EXISTS on it so the schema exists - // before any test hits the GET /api/users endpoint. - const { db } = await import("../src/db"); - - db.run(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ) - `); - - app = new Hono(); - registerRoutes(app); - }); - - describe("GET /api/users", () => { - test("returns empty users array when no users exist", async () => { - const res = await app.request("/api/users"); - expect(res.status).toBe(200); - const data = await res.json(); - expect(Array.isArray(data.users)).toBe(true); - expect(data.users).toEqual([]); - }); - - test("accepts limit and offset query parameters", async () => { - const res = await app.request("/api/users?limit=10&offset=5"); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.pagination.limit).toBe(10); - expect(data.pagination.offset).toBe(5); - }); - - test("returns 400 for invalid limit", async () => { - const res = await app.request("/api/users?limit=-1"); - expect(res.status).toBe(400); - const data = await res.json(); - expect(data.error).toContain("Invalid pagination query parameters"); - }); - - test("returns 400 for non-numeric limit", async () => { - const res = await app.request("/api/users?limit=abc"); - expect(res.status).toBe(400); - const data = await res.json(); - expect(data.error).toContain("Invalid pagination query parameters"); - }); - }); - - describe("POST /api/users", () => { - // NOTE: The POST route currently has a TODO stub — it validates the - // payload but does not persist to the DB. These tests reflect that - // intentional current behavior. When the real insert is implemented, - // update the first test to expect 201 and check for a returned `id`. - test("validates payload but does not persist (stub behavior)", async () => { - const res = await app.request("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "test@example.com", name: "Test User" }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.message).toBe("User payload validated (not persisted)"); - expect(data.user.email).toBe("test@example.com"); - expect(data.user.name).toBe("Test User"); - }); - - test("returns 400 for missing email", async () => { - const res = await app.request("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "Test User" }), - }); - expect(res.status).toBe(400); - }); - - test("returns 400 for invalid email", async () => { - const res = await app.request("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "not-an-email", name: "Test User" }), - }); - expect(res.status).toBe(400); - }); - - test("returns 400 for malformed JSON", async () => { - const res = await app.request("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "not valid json", - }); - expect(res.status).toBe(400); - }); - }); -}); +import { beforeAll, describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { registerRoutes } from "../src/routes"; + +describe("users CRUD endpoint", () => { + let app: Hono; + + beforeAll(async () => { + // Import db AFTER app modules load — this is the exact same + // db instance the route handlers will use at runtime. + // We run CREATE TABLE IF NOT EXISTS on it so the schema exists + // before any test hits the GET /api/users endpoint. + const { db } = await import("../src/db"); + + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); + + app = new Hono(); + registerRoutes(app); + }); + + describe("GET /api/users", () => { + test("returns empty users array when no users exist", async () => { + const res = await app.request("/api/users"); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.users)).toBe(true); + expect(data.users).toEqual([]); + }); + + test("accepts limit and offset query parameters", async () => { + const res = await app.request("/api/users?limit=10&offset=5"); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.pagination.limit).toBe(10); + expect(data.pagination.offset).toBe(5); + }); + + test("returns 400 for invalid limit", async () => { + const res = await app.request("/api/users?limit=-1"); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("Invalid pagination query parameters"); + }); + + test("returns 400 for non-numeric limit", async () => { + const res = await app.request("/api/users?limit=abc"); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("Invalid pagination query parameters"); + }); + }); + + describe("POST /api/users", () => { + // NOTE: The POST route currently has a TODO stub — it validates the + // payload but does not persist to the DB. These tests reflect that + // intentional current behavior. When the real insert is implemented, + // update the first test to expect 201 and check for a returned `id`. + test("validates payload but does not persist (stub behavior)", async () => { + const res = await app.request("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "test@example.com", name: "Test User" }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.message).toBe("User payload validated (not persisted)"); + expect(data.user.email).toBe("test@example.com"); + expect(data.user.name).toBe("Test User"); + }); + + test("returns 400 for missing email", async () => { + const res = await app.request("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test User" }), + }); + expect(res.status).toBe(400); + }); + + test("returns 400 for invalid email", async () => { + const res = await app.request("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "not-an-email", name: "Test User" }), + }); + expect(res.status).toBe(400); + }); + + test("returns 400 for malformed JSON", async () => { + const res = await app.request("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not valid json", + }); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/templates/base/test/health.test.ts b/templates/base/test/health.test.ts index 032715b..27c695d 100644 --- a/templates/base/test/health.test.ts +++ b/templates/base/test/health.test.ts @@ -1,22 +1,22 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { registerRoutes } from "../src/routes"; - -describe("health endpoint", () => { - let app: Hono; - - beforeAll(() => { - app = new Hono(); - registerRoutes(app); - }); - - test("GET /health returns 200 with healthy status", async () => { - const res = await app.request("/health"); - expect(res.status).toBe(200); - - const data = await res.json(); - expect(data.status).toBe("healthy"); - expect(data.database).toBe("connected"); - expect(data.timestamp).toBeDefined(); - }); -}); +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { registerRoutes } from "../src/routes"; + +describe("health endpoint", () => { + let app: Hono; + + beforeAll(() => { + app = new Hono(); + registerRoutes(app); + }); + + test("GET /health returns 200 with healthy status", async () => { + const res = await app.request("/health"); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.status).toBe("healthy"); + expect(data.database).toBe("connected"); + expect(data.timestamp).toBeDefined(); + }); +}); diff --git a/templates/iac/betterbase.config.ts b/templates/iac/betterbase.config.ts index e46431f..0df7e35 100644 --- a/templates/iac/betterbase.config.ts +++ b/templates/iac/betterbase.config.ts @@ -1,97 +1,97 @@ -/** - * BetterBase Configuration File - IaC Template - * - * This file defines the configuration for your BetterBase IaC project. - * The IaC template uses betterbase/ functions with auto-migration. - * - * Supported database providers: - * - 'postgres': Standard PostgreSQL (uses DATABASE_URL) - * - 'neon': Neon serverless PostgreSQL (uses DATABASE_URL) - * - 'supabase': Supabase PostgreSQL (uses DATABASE_URL) - * - 'planetscale': PlanetScale MySQL (uses DATABASE_URL) - * - 'turso': Turso libSQL (uses TURSO_URL and TURSO_AUTH_TOKEN) - * - 'managed': BetterBase managed database (coming soon) - * - * Environment variables: - * - DATABASE_URL: Connection string for postgres, neon, supabase, planetscale - * - TURSO_URL: libSQL connection URL (for turso) - * - TURSO_AUTH_TOKEN: Auth token for Turso database - */ - -import { defineConfig } from "@betterbase/core"; -import type { BetterBaseConfig } from "@betterbase/core"; - -/** - * Database provider type - * Update this to match your provider: postgres, neon, supabase, planetscale, turso, managed - */ -type ProviderType = "postgres" | "neon" | "supabase" | "planetscale" | "turso" | "managed"; - -/** - * IaC Project Configuration - * - * The IaC template uses infrastructure-as-code with betterbase/ functions. - * Define your schema in betterbase/schema.ts - migrations are auto-generated. - * - * @example - * ```typescript - * export default defineConfig({ - * project: { - * name: 'my-iac-project', - * }, - * provider: { - * type: 'neon', - * connectionString: process.env.DATABASE_URL, - * }, - * }) satisfies BetterBaseConfig - * ``` - */ -export default defineConfig({ - /** Project name - used for identification and metadata */ - project: { - name: "my-iac-project", - }, - - /** - * Database provider configuration - * - * Change the type to match your provider: - * - 'postgres': Raw PostgreSQL - * - 'neon': Neon serverless Postgres - * - 'supabase': Supabase Postgres - * - 'planetscale': PlanetScale MySQL - * - 'turso': Turso edge database - * - 'managed': BetterBase managed (coming soon) - */ - provider: { - /** The database provider type */ - type: "postgres" as ProviderType, - - /** - * Database connection string - * Format: postgresql://user:pass@host:port/db for PostgreSQL - * Format: mysql://user:pass@host:port/db for MySQL/PlanetScale - */ - connectionString: process.env.DATABASE_URL, - - // Turso-specific (uncomment if using Turso): - // url: process.env.TURSO_URL, - // authToken: process.env.TURSO_AUTH_TOKEN, - }, - - /** - * GraphQL API configuration - * Set enabled: false to disable the GraphQL API - */ - graphql: { - enabled: true, - }, - - /** - * Auto-REST API configuration - * Automatically generates CRUD routes for all tables in the schema - */ - autoRest: { - enabled: true, - }, -}) satisfies BetterBaseConfig; +/** + * BetterBase Configuration File - IaC Template + * + * This file defines the configuration for your BetterBase IaC project. + * The IaC template uses betterbase/ functions with auto-migration. + * + * Supported database providers: + * - 'postgres': Standard PostgreSQL (uses DATABASE_URL) + * - 'neon': Neon serverless PostgreSQL (uses DATABASE_URL) + * - 'supabase': Supabase PostgreSQL (uses DATABASE_URL) + * - 'planetscale': PlanetScale MySQL (uses DATABASE_URL) + * - 'turso': Turso libSQL (uses TURSO_URL and TURSO_AUTH_TOKEN) + * - 'managed': BetterBase managed database (coming soon) + * + * Environment variables: + * - DATABASE_URL: Connection string for postgres, neon, supabase, planetscale + * - TURSO_URL: libSQL connection URL (for turso) + * - TURSO_AUTH_TOKEN: Auth token for Turso database + */ + +import { defineConfig } from "@betterbase/core"; +import type { BetterBaseConfig } from "@betterbase/core"; + +/** + * Database provider type + * Update this to match your provider: postgres, neon, supabase, planetscale, turso, managed + */ +type ProviderType = "postgres" | "neon" | "supabase" | "planetscale" | "turso" | "managed"; + +/** + * IaC Project Configuration + * + * The IaC template uses infrastructure-as-code with betterbase/ functions. + * Define your schema in betterbase/schema.ts - migrations are auto-generated. + * + * @example + * ```typescript + * export default defineConfig({ + * project: { + * name: 'my-iac-project', + * }, + * provider: { + * type: 'neon', + * connectionString: process.env.DATABASE_URL, + * }, + * }) satisfies BetterBaseConfig + * ``` + */ +export default defineConfig({ + /** Project name - used for identification and metadata */ + project: { + name: "my-iac-project", + }, + + /** + * Database provider configuration + * + * Change the type to match your provider: + * - 'postgres': Raw PostgreSQL + * - 'neon': Neon serverless Postgres + * - 'supabase': Supabase Postgres + * - 'planetscale': PlanetScale MySQL + * - 'turso': Turso edge database + * - 'managed': BetterBase managed (coming soon) + */ + provider: { + /** The database provider type */ + type: "postgres" as ProviderType, + + /** + * Database connection string + * Format: postgresql://user:pass@host:port/db for PostgreSQL + * Format: mysql://user:pass@host:port/db for MySQL/PlanetScale + */ + connectionString: process.env.DATABASE_URL, + + // Turso-specific (uncomment if using Turso): + // url: process.env.TURSO_URL, + // authToken: process.env.TURSO_AUTH_TOKEN, + }, + + /** + * GraphQL API configuration + * Set enabled: false to disable the GraphQL API + */ + graphql: { + enabled: true, + }, + + /** + * Auto-REST API configuration + * Automatically generates CRUD routes for all tables in the schema + */ + autoRest: { + enabled: true, + }, +}) satisfies BetterBaseConfig; diff --git a/templates/iac/betterbase/cron.ts b/templates/iac/betterbase/cron.ts index daab7e2..e7d6e37 100644 --- a/templates/iac/betterbase/cron.ts +++ b/templates/iac/betterbase/cron.ts @@ -1,5 +1,5 @@ -// import { cron } from "@betterbase/core/iac"; -// import { api } from "./_generated/api"; -// -// Example: run cleanup every day at midnight UTC -// cron("daily-cleanup", "0 0 * * *", api.mutations.todos.cleanup, {}); \ No newline at end of file +// import { cron } from "@betterbase/core/iac"; +// import { api } from "./_generated/api"; +// +// Example: run cleanup every day at midnight UTC +// cron("daily-cleanup", "0 0 * * *", api.mutations.todos.cleanup, {}); \ No newline at end of file diff --git a/templates/iac/betterbase/mutations/todos.ts b/templates/iac/betterbase/mutations/todos.ts index 6b29b62..e58592a 100644 --- a/templates/iac/betterbase/mutations/todos.ts +++ b/templates/iac/betterbase/mutations/todos.ts @@ -1,23 +1,23 @@ -import { mutation } from "@betterbase/core/iac"; -import { v } from "@betterbase/core/iac"; - -export const createTodo = mutation({ - args: { text: v.string() }, - handler: async (ctx, args) => { - return ctx.db.insert("todos", { text: args.text, completed: false }); - }, -}); - -export const toggleTodo = mutation({ - args: { id: v.id("todos"), completed: v.boolean() }, - handler: async (ctx, args) => { - await ctx.db.patch("todos", args.id, { completed: args.completed }); - }, -}); - -export const deleteTodo = mutation({ - args: { id: v.id("todos") }, - handler: async (ctx, args) => { - await ctx.db.delete("todos", args.id); - }, -}); \ No newline at end of file +import { mutation } from "@betterbase/core/iac"; +import { v } from "@betterbase/core/iac"; + +export const createTodo = mutation({ + args: { text: v.string() }, + handler: async (ctx, args) => { + return ctx.db.insert("todos", { text: args.text, completed: false }); + }, +}); + +export const toggleTodo = mutation({ + args: { id: v.id("todos"), completed: v.boolean() }, + handler: async (ctx, args) => { + await ctx.db.patch("todos", args.id, { completed: args.completed }); + }, +}); + +export const deleteTodo = mutation({ + args: { id: v.id("todos") }, + handler: async (ctx, args) => { + await ctx.db.delete("todos", args.id); + }, +}); \ No newline at end of file diff --git a/templates/iac/betterbase/queries/todos.ts b/templates/iac/betterbase/queries/todos.ts index 49e329c..9a3a3dc 100644 --- a/templates/iac/betterbase/queries/todos.ts +++ b/templates/iac/betterbase/queries/todos.ts @@ -1,16 +1,16 @@ -import { query } from "@betterbase/core/iac"; -import { v } from "@betterbase/core/iac"; - -export const listTodos = query({ - args: {}, - handler: async (ctx) => { - return ctx.db.query("todos").order("desc").take(100).collect(); - }, -}); - -export const getTodo = query({ - args: { id: v.id("todos") }, - handler: async (ctx, args) => { - return ctx.db.get("todos", args.id); - }, -}); \ No newline at end of file +import { query } from "@betterbase/core/iac"; +import { v } from "@betterbase/core/iac"; + +export const listTodos = query({ + args: {}, + handler: async (ctx) => { + return ctx.db.query("todos").order("desc").take(100).collect(); + }, +}); + +export const getTodo = query({ + args: { id: v.id("todos") }, + handler: async (ctx, args) => { + return ctx.db.get("todos", args.id); + }, +}); \ No newline at end of file diff --git a/templates/iac/betterbase/schema.ts b/templates/iac/betterbase/schema.ts index d0595cb..40179c6 100644 --- a/templates/iac/betterbase/schema.ts +++ b/templates/iac/betterbase/schema.ts @@ -1,11 +1,11 @@ -import { defineSchema, defineTable, v } from "@betterbase/core/iac"; - -export default defineSchema({ - todos: defineTable({ - text: v.string(), - completed: v.boolean(), - authorId: v.optional(v.string()), - }) - .index("by_author", ["authorId"]) - .index("by_completed", ["completed", "_createdAt"]), -}); \ No newline at end of file +import { defineSchema, defineTable, v } from "@betterbase/core/iac"; + +export default defineSchema({ + todos: defineTable({ + text: v.string(), + completed: v.boolean(), + authorId: v.optional(v.string()), + }) + .index("by_author", ["authorId"]) + .index("by_completed", ["completed", "_createdAt"]), +}); \ No newline at end of file diff --git a/templates/iac/src/index.ts b/templates/iac/src/index.ts index c9a4b12..eb25033 100644 --- a/templates/iac/src/index.ts +++ b/templates/iac/src/index.ts @@ -1,20 +1,20 @@ -import { join } from "path"; -import { discoverFunctions, setFunctionRegistry } from "@betterbase/core/iac"; -import { betterbaseRouter } from "@betterbase/server/routes/betterbase"; -import { Hono } from "hono"; -import { cors } from "hono/cors"; - -const app = new Hono(); -app.use("*", cors()); - -// Discover and register betterbase/ functions on startup -const fns = await discoverFunctions(join(process.cwd(), "betterbase")); -setFunctionRegistry(fns); - -// Mount the betterbase router — this is your entire API surface -app.route("/betterbase", betterbaseRouter); - -// Health check -app.get("/health", (c) => c.json({ status: "ok" })); - -export default { port: 3000, fetch: app.fetch }; +import { join } from "path"; +import { discoverFunctions, setFunctionRegistry } from "@betterbase/core/iac"; +import { betterbaseRouter } from "@betterbase/server/routes/betterbase"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; + +const app = new Hono(); +app.use("*", cors()); + +// Discover and register betterbase/ functions on startup +const fns = await discoverFunctions(join(process.cwd(), "betterbase")); +setFunctionRegistry(fns); + +// Mount the betterbase router — this is your entire API surface +app.route("/betterbase", betterbaseRouter); + +// Health check +app.get("/health", (c) => c.json({ status: "ok" })); + +export default { port: 3000, fetch: app.fetch };