diff --git a/bun.lock b/bun.lock index fbb1ba3..e9b1045 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@kubernetes/client-node": "^1.4.0", "@modelcontextprotocol/sdk": "^1.25.2", "axios": "^1.13.2", + "bcryptjs": "^3.0.3", "bullmq": "^5.66.5", "cors": "^2.8.5", "dotenv": "^17.2.3", @@ -21,6 +22,7 @@ "zod": "^4.3.5", }, "devDependencies": { + "@types/bcryptjs": "^3.0.0", "@types/bun": "latest", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", @@ -153,6 +155,8 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], @@ -235,6 +239,8 @@ "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], + "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], diff --git a/drizzle/migrations/0001_opposite_warbird.sql b/drizzle/migrations/0001_opposite_warbird.sql new file mode 100644 index 0000000..1972475 --- /dev/null +++ b/drizzle/migrations/0001_opposite_warbird.sql @@ -0,0 +1,24 @@ +CREATE TABLE `sessions` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(36) NOT NULL, + `expires_at` timestamp NOT NULL, + `created_at` timestamp DEFAULT (now()), + CONSTRAINT `sessions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` varchar(36) NOT NULL, + `email` varchar(255) NOT NULL, + `username` varchar(100) NOT NULL, + `password_hash` varchar(255) NOT NULL, + `created_at` timestamp DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `users_id` PRIMARY KEY(`id`), + CONSTRAINT `users_email_unique` UNIQUE(`email`), + CONSTRAINT `users_username_unique` UNIQUE(`username`) +); +--> statement-breakpoint +ALTER TABLE `sessions` ADD CONSTRAINT `sessions_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_user_id` ON `sessions` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_expires_at` ON `sessions` (`expires_at`);--> statement-breakpoint +CREATE INDEX `idx_email` ON `users` (`email`); \ No newline at end of file diff --git a/drizzle/migrations/meta/0001_snapshot.json b/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..a07893a --- /dev/null +++ b/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,578 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "ada6a640-177d-4c35-b7e0-e1d20c9adabb", + "prevId": "3f9067f3-0e1d-440b-917e-e2c879a5e283", + "tables": { + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pod_name": { + "name": "pod_name", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "k8s_namespace": { + "name": "k8s_namespace", + "type": "varchar(63)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'agents'" + }, + "status": { + "name": "status", + "type": "enum('idle','busy','error','offline')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'idle'" + }, + "current_task_id": { + "name": "current_task_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tasks_completed": { + "name": "tasks_completed", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_status": { + "name": "idx_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agents_id": { + "name": "agents_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "agents_pod_name_unique": { + "name": "agents_pod_name_unique", + "columns": [ + "pod_name" + ] + } + }, + "checkConstraint": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gitea_repo_id": { + "name": "gitea_repo_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gitea_repo_url": { + "name": "gitea_repo_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gitea_owner": { + "name": "gitea_owner", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gitea_repo_name": { + "name": "gitea_repo_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'main'" + }, + "k8s_namespace": { + "name": "k8s_namespace", + "type": "varchar(63)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "docker_image": { + "name": "docker_image", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env_vars": { + "name": "env_vars", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "replicas": { + "name": "replicas", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "cpu_limit": { + "name": "cpu_limit", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'500m'" + }, + "memory_limit": { + "name": "memory_limit", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'512Mi'" + }, + "status": { + "name": "status", + "type": "enum('active','paused','archived')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_status": { + "name": "idx_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "projects_id": { + "name": "projects_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "projects_k8s_namespace_unique": { + "name": "projects_k8s_namespace_unique", + "columns": [ + "k8s_namespace" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "idx_user_id": { + "name": "idx_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_expires_at": { + "name": "idx_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "enum('low','medium','high','urgent')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'medium'" + }, + "state": { + "name": "state", + "type": "enum('backlog','in_progress','needs_input','ready_to_test','approved','staging','production')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'backlog'" + }, + "assigned_agent_id": { + "name": "assigned_agent_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preview_url": { + "name": "preview_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_project_state": { + "name": "idx_project_state", + "columns": [ + "project_id", + "state" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assigned_agent_id_agents_id_fk": { + "name": "tasks_assigned_agent_id_agents_id_fk", + "tableFrom": "tasks", + "tableTo": "agents", + "columnsFrom": [ + "assigned_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tasks_id": { + "name": "tasks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_email": { + "name": "idx_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 5a0a9d3..a522094 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1768858601280, "tag": "0000_charming_stature", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1768868010676, + "tag": "0001_opposite_warbird", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index eefde2e..1d80e93 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format": "prettier --write src/**/*.ts" }, "devDependencies": { + "@types/bcryptjs": "^3.0.0", "@types/bun": "latest", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", @@ -32,6 +33,7 @@ "@kubernetes/client-node": "^1.4.0", "@modelcontextprotocol/sdk": "^1.25.2", "axios": "^1.13.2", + "bcryptjs": "^3.0.3", "bullmq": "^5.66.5", "cors": "^2.8.5", "dotenv": "^17.2.3", diff --git a/src/api/routes/auth.ts b/src/api/routes/auth.ts new file mode 100644 index 0000000..622ceba --- /dev/null +++ b/src/api/routes/auth.ts @@ -0,0 +1,392 @@ +/** + * Authentication Routes + * Using session-based auth with HTTPOnly cookies (Lucia Auth pattern) + */ + +import { db } from '../../db/client' +import { users, sessions } from '../../db/schema' +import { eq } from 'drizzle-orm' +import { randomUUID } from 'crypto' +import bcrypt from 'bcryptjs' + +// Session duration: 7 days +const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000 + +// Cookie options +const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/', + maxAge: SESSION_DURATION_MS / 1000, // in seconds +} + +/** + * Hash password using bcrypt + */ +async function hashPassword(password: string): Promise { + return await bcrypt.hash(password, 10) +} + +/** + * Verify password against hash + */ +async function verifyPassword(password: string, hash: string): Promise { + return await bcrypt.compare(password, hash) +} + +/** + * Generate random session ID + */ +function generateSessionId(): string { + return randomUUID().replace(/-/g, '') +} + +/** + * Create session in database and return session cookie header + */ +async function createSession(userId: string): Promise<{ sessionId: string; cookieHeader: string }> { + const sessionId = generateSessionId() + const expiresAt = new Date(Date.now() + SESSION_DURATION_MS) + + await db.insert(sessions).values({ + id: sessionId, + userId, + expiresAt, + }) + + const cookieHeader = serializeCookie('session', sessionId, COOKIE_OPTIONS) + + return { sessionId, cookieHeader } +} + +/** + * Validate session from cookie and return user + */ +async function validateSession(sessionId: string) { + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1) + + if (!session) { + return null + } + + // Check if session expired + if (session.expiresAt < new Date()) { + await db.delete(sessions).where(eq(sessions.id, sessionId)) + return null + } + + // Get user + const [user] = await db + .select({ + id: users.id, + email: users.email, + username: users.username, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.id, session.userId)) + .limit(1) + + return user || null +} + +/** + * Delete session from database + */ +async function deleteSession(sessionId: string) { + await db.delete(sessions).where(eq(sessions.id, sessionId)) +} + +/** + * Serialize cookie header + */ +function serializeCookie( + name: string, + value: string, + options: typeof COOKIE_OPTIONS +): string { + const parts = [`${name}=${value}`] + + if (options.httpOnly) parts.push('HttpOnly') + if (options.secure) parts.push('Secure') + if (options.sameSite) parts.push(`SameSite=${options.sameSite}`) + if (options.path) parts.push(`Path=${options.path}`) + if (options.maxAge) parts.push(`Max-Age=${options.maxAge}`) + + return parts.join('; ') +} + +/** + * Parse cookies from request header + */ +function parseCookies(cookieHeader: string | null): Record { + if (!cookieHeader) return {} + + return cookieHeader.split(';').reduce((cookies, cookie) => { + const [name, value] = cookie.trim().split('=') + if (name && value) { + cookies[name] = value + } + return cookies + }, {} as Record) +} + +/** + * Get session ID from request cookies + */ +function getSessionId(req: Request): string | null { + const cookieHeader = req.headers.get('cookie') + const cookies = parseCookies(cookieHeader) + return cookies.session || null +} + +/** + * POST /api/auth/register + * Create new user account + */ +async function handleRegister(req: Request): Promise { + try { + const body = await req.json() + const { email, username, password } = body + + // Validation + if (!email || !username || !password) { + return Response.json( + { success: false, message: 'Email, username, and password are required' }, + { status: 400 } + ) + } + + if (password.length < 8) { + return Response.json( + { success: false, message: 'Password must be at least 8 characters' }, + { status: 400 } + ) + } + + // Check if user already exists + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1) + + if (existingUser) { + return Response.json( + { success: false, message: 'User with this email already exists' }, + { status: 409 } + ) + } + + // Create user + const userId = randomUUID() + const passwordHash = await hashPassword(password) + + await db.insert(users).values({ + id: userId, + email, + username, + passwordHash, + }) + + // Create session + const { cookieHeader } = await createSession(userId) + + // Return user (without password hash) + const [user] = await db + .select({ + id: users.id, + email: users.email, + username: users.username, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1) + + return Response.json( + { + success: true, + data: { user }, + }, + { + status: 201, + headers: { + 'Set-Cookie': cookieHeader, + }, + } + ) + } catch (error: any) { + console.error('Registration error:', error) + return Response.json( + { success: false, message: 'Registration failed' }, + { status: 500 } + ) + } +} + +/** + * POST /api/auth/login + * Authenticate user and create session + */ +async function handleLogin(req: Request): Promise { + try { + const body = await req.json() + const { email, password } = body + + if (!email || !password) { + return Response.json( + { success: false, message: 'Email and password are required' }, + { status: 400 } + ) + } + + // Get user + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1) + + if (!user) { + return Response.json( + { success: false, message: 'Invalid email or password' }, + { status: 401 } + ) + } + + // Verify password + const valid = await verifyPassword(password, user.passwordHash) + + if (!valid) { + return Response.json( + { success: false, message: 'Invalid email or password' }, + { status: 401 } + ) + } + + // Create session + const { cookieHeader } = await createSession(user.id) + + return Response.json( + { + success: true, + data: { + user: { + id: user.id, + email: user.email, + username: user.username, + createdAt: user.createdAt, + }, + }, + }, + { + headers: { + 'Set-Cookie': cookieHeader, + }, + } + ) + } catch (error: any) { + console.error('Login error:', error) + return Response.json({ success: false, message: 'Login failed' }, { status: 500 }) + } +} + +/** + * POST /api/auth/logout + * Delete session + */ +async function handleLogout(req: Request): Promise { + try { + const sessionId = getSessionId(req) + + if (sessionId) { + await deleteSession(sessionId) + } + + // Clear cookie + const clearCookieHeader = serializeCookie('session', '', { + ...COOKIE_OPTIONS, + maxAge: 0, + }) + + return Response.json( + { success: true, message: 'Logged out successfully' }, + { + headers: { + 'Set-Cookie': clearCookieHeader, + }, + } + ) + } catch (error: any) { + console.error('Logout error:', error) + return Response.json({ success: false, message: 'Logout failed' }, { status: 500 }) + } +} + +/** + * GET /api/auth/me + * Get current user from session + */ +async function handleMe(req: Request): Promise { + try { + const sessionId = getSessionId(req) + + if (!sessionId) { + return Response.json( + { success: false, message: 'Not authenticated' }, + { status: 401 } + ) + } + + const user = await validateSession(sessionId) + + if (!user) { + return Response.json( + { success: false, message: 'Invalid or expired session' }, + { status: 401 } + ) + } + + return Response.json({ + success: true, + data: user, + }) + } catch (error: any) { + console.error('Get current user error:', error) + return Response.json( + { success: false, message: 'Failed to get current user' }, + { status: 500 } + ) + } +} + +/** + * Route handler for /api/auth/* + */ +export async function handleAuthRoutes(req: Request, url: URL): Promise { + const path = url.pathname.replace('/api/auth', '') + + if (path === '/register' && req.method === 'POST') { + return handleRegister(req) + } + + if (path === '/login' && req.method === 'POST') { + return handleLogin(req) + } + + if (path === '/logout' && req.method === 'POST') { + return handleLogout(req) + } + + if (path === '/me' && req.method === 'GET') { + return handleMe(req) + } + + return Response.json({ message: 'Not found' }, { status: 404 }) +} diff --git a/src/api/routes/index.ts b/src/api/routes/index.ts index ba2703a..e7b497f 100644 --- a/src/api/routes/index.ts +++ b/src/api/routes/index.ts @@ -3,6 +3,7 @@ * Exports all route handlers */ +export { handleAuthRoutes } from './auth' export { handleProjectRoutes } from './projects' export { handleTaskRoutes } from './tasks' export { handleAgentRoutes } from './agents' diff --git a/src/db/apply-migrations.ts b/src/db/apply-migrations.ts new file mode 100644 index 0000000..016e7ec --- /dev/null +++ b/src/db/apply-migrations.ts @@ -0,0 +1,60 @@ +/** + * Apply pending migrations directly + */ + +import mysql from 'mysql2/promise' +import { readFileSync } from 'fs' +import { join } from 'path' + +async function applyMigrations() { + console.log('📝 Applying pending migrations...') + + const connection = await mysql.createConnection({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '3306'), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }) + + try { + // Read the latest migration file + const migrationPath = join(import.meta.dir, '../../drizzle/migrations/0001_opposite_warbird.sql') + const migrationSQL = readFileSync(migrationPath, 'utf-8') + + // Split by statement-breakpoint and execute each statement + const statements = migrationSQL + .split('-->') + .map((s) => s.trim()) + .filter((s) => s && !s.startsWith('statement-breakpoint')) + + console.log(`Found ${statements.length} statements to execute`) + + for (const statement of statements) { + if (statement) { + try { + await connection.execute(statement) + console.log('✅ Executed statement') + } catch (error: any) { + // Skip if table already exists + if (error.code === 'ER_TABLE_EXISTS_ERROR' || error.errno === 1050) { + console.log('⚠️ Table already exists, skipping...') + } else if (error.code === 'ER_DUP_KEYNAME' || error.errno === 1061) { + console.log('⚠️ Index already exists, skipping...') + } else { + throw error + } + } + } + } + + console.log('✅ Migrations applied successfully') + await connection.end() + } catch (error) { + console.error('❌ Migration failed:', error) + await connection.end() + throw error + } +} + +await applyMigrations() diff --git a/src/db/force-migrate.ts b/src/db/force-migrate.ts new file mode 100644 index 0000000..1517c34 --- /dev/null +++ b/src/db/force-migrate.ts @@ -0,0 +1,76 @@ +/** + * Force apply all migration statements + */ + +import mysql from 'mysql2/promise' + +const statements = [ + `CREATE TABLE IF NOT EXISTS \`sessions\` ( + \`id\` varchar(255) NOT NULL, + \`user_id\` varchar(36) NOT NULL, + \`expires_at\` timestamp NOT NULL, + \`created_at\` timestamp DEFAULT (now()), + CONSTRAINT \`sessions_id\` PRIMARY KEY(\`id\`) + )`, + `CREATE TABLE IF NOT EXISTS \`users\` ( + \`id\` varchar(36) NOT NULL, + \`email\` varchar(255) NOT NULL, + \`username\` varchar(100) NOT NULL, + \`password_hash\` varchar(255) NOT NULL, + \`created_at\` timestamp DEFAULT (now()), + \`updated_at\` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT \`users_id\` PRIMARY KEY(\`id\`), + CONSTRAINT \`users_email_unique\` UNIQUE(\`email\`), + CONSTRAINT \`users_username_unique\` UNIQUE(\`username\`) + )`, + `ALTER TABLE \`sessions\` + ADD CONSTRAINT \`sessions_user_id_users_id_fk\` + FOREIGN KEY (\`user_id\`) REFERENCES \`users\`(\`id\`) + ON DELETE cascade ON UPDATE no action`, + `CREATE INDEX IF NOT EXISTS \`idx_user_id\` ON \`sessions\` (\`user_id\`)`, + `CREATE INDEX IF NOT EXISTS \`idx_expires_at\` ON \`sessions\` (\`expires_at\`)`, + `CREATE INDEX IF NOT EXISTS \`idx_email\` ON \`users\` (\`email\`)`, +] + +async function forceMigrate() { + console.log('🔧 Force migrating database...') + + const connection = await mysql.createConnection({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '3306'), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }) + + try { + for (const [index, statement] of statements.entries()) { + console.log(`Executing statement ${index + 1}/${statements.length}...`) + try { + await connection.execute(statement) + console.log('✅ Success') + } catch (error: any) { + if ( + error.code === 'ER_TABLE_EXISTS_ERROR' || + error.code === 'ER_DUP_KEYNAME' || + error.errno === 1050 || + error.errno === 1061 + ) { + console.log('⚠️ Already exists, skipping...') + } else { + console.error('Error:', error.message) + // Continue with other statements + } + } + } + + console.log('✅ All migrations applied') + await connection.end() + } catch (error) { + console.error('❌ Migration failed:', error) + await connection.end() + throw error + } +} + +await forceMigrate() diff --git a/src/db/schema.ts b/src/db/schema.ts index 0d8f454..5291651 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -17,6 +17,39 @@ import { index, } from 'drizzle-orm/mysql-core' +// ============================================ +// USERS TABLE +// ============================================ + +export const users = mysqlTable('users', { + id: varchar('id', { length: 36 }).primaryKey(), + email: varchar('email', { length: 255 }).notNull().unique(), + username: varchar('username', { length: 100 }).notNull().unique(), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + + // Timestamps + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), +}, (table) => ({ + emailIdx: index('idx_email').on(table.email), +})) + +// ============================================ +// SESSIONS TABLE +// ============================================ + +export const sessions = mysqlTable('sessions', { + id: varchar('id', { length: 255 }).primaryKey(), + userId: varchar('user_id', { length: 36 }).notNull().references(() => users.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at').notNull(), + + // Timestamps + createdAt: timestamp('created_at').defaultNow(), +}, (table) => ({ + userIdIdx: index('idx_user_id').on(table.userId), + expiresAtIdx: index('idx_expires_at').on(table.expiresAt), +})) + // ============================================ // PROJECTS TABLE // ============================================ diff --git a/src/index.ts b/src/index.ts index 208d489..47351a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { runMigrations } from './db/migrate' import { testConnection } from './db/client' -import { handleProjectRoutes, handleTaskRoutes, handleAgentRoutes } from './api/routes' +import { handleAuthRoutes, handleProjectRoutes, handleTaskRoutes, handleAgentRoutes } from './api/routes' console.log('🚀 Starting AiWorker Backend...') console.log(`Bun version: ${Bun.version}`) @@ -41,6 +41,11 @@ const server = Bun.serve({ return handleHealthCheck() } + // Auth routes + if (url.pathname.startsWith('/api/auth')) { + return handleAuthRoutes(req, url) + } + // API routes if (url.pathname.startsWith('/api/projects')) { return handleProjectRoutes(req, url) @@ -61,6 +66,10 @@ const server = Bun.serve({ version: '1.0.0', endpoints: [ 'GET /api/health', + 'POST /api/auth/register', + 'POST /api/auth/login', + 'POST /api/auth/logout', + 'GET /api/auth/me', 'GET /api/projects', 'GET /api/projects/:id', 'POST /api/projects', diff --git a/test-auth.ts b/test-auth.ts new file mode 100644 index 0000000..7a0651d --- /dev/null +++ b/test-auth.ts @@ -0,0 +1,37 @@ +/** + * Test authentication directly + */ + +import { db } from './src/db/client' +import { users } from './src/db/schema' +import { randomUUID } from 'crypto' +import bcrypt from 'bcryptjs' + +async function testAuth() { + try { + console.log('Testing auth...') + + // Hash password + const passwordHash = await bcrypt.hash('test123', 10) + console.log('Password hash:', passwordHash) + + // Insert user + const userId = randomUUID() + console.log('Inserting user with ID:', userId) + + await db.insert(users).values({ + id: userId, + email: 'test@test.com', + username: 'testuser', + passwordHash, + }) + + console.log('User inserted successfully!') + } catch (error) { + console.error('Error:', error) + } + + process.exit(0) +} + +testAuth()