Add authentication system with session-based auth
All checks were successful
Build and Push Backend / build (push) Successful in 20s

- Implement register, login, logout, and me endpoints
- Use bcryptjs for password hashing
- HTTPOnly secure cookies for sessions (Lucia Auth pattern)
- Users and sessions tables with proper relations
- 7-day session duration with auto-expiry

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hector Ros
2026-01-20 01:56:25 +01:00
parent 5672127593
commit 1dc0ab515d
12 changed files with 1226 additions and 1 deletions

View File

@@ -8,6 +8,7 @@
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"bullmq": "^5.66.5", "bullmq": "^5.66.5",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
@@ -21,6 +22,7 @@
"zod": "^4.3.5", "zod": "^4.3.5",
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/bun": "latest", "@types/bun": "latest",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
@@ -153,6 +155,8 @@
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], "@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/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=="], "@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=="], "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=="], "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=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],

View File

@@ -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`);

View File

@@ -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": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1768858601280, "when": 1768858601280,
"tag": "0000_charming_stature", "tag": "0000_charming_stature",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1768868010676,
"tag": "0001_opposite_warbird",
"breakpoints": true
} }
] ]
} }

View File

@@ -15,6 +15,7 @@
"format": "prettier --write src/**/*.ts" "format": "prettier --write src/**/*.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/bun": "latest", "@types/bun": "latest",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
@@ -32,6 +33,7 @@
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"bullmq": "^5.66.5", "bullmq": "^5.66.5",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",

392
src/api/routes/auth.ts Normal file
View File

@@ -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<string> {
return await bcrypt.hash(password, 10)
}
/**
* Verify password against hash
*/
async function verifyPassword(password: string, hash: string): Promise<boolean> {
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<string, string> {
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<string, string>)
}
/**
* 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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 })
}

View File

@@ -3,6 +3,7 @@
* Exports all route handlers * Exports all route handlers
*/ */
export { handleAuthRoutes } from './auth'
export { handleProjectRoutes } from './projects' export { handleProjectRoutes } from './projects'
export { handleTaskRoutes } from './tasks' export { handleTaskRoutes } from './tasks'
export { handleAgentRoutes } from './agents' export { handleAgentRoutes } from './agents'

View File

@@ -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()

76
src/db/force-migrate.ts Normal file
View File

@@ -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()

View File

@@ -17,6 +17,39 @@ import {
index, index,
} from 'drizzle-orm/mysql-core' } 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 // PROJECTS TABLE
// ============================================ // ============================================

View File

@@ -5,7 +5,7 @@
import { runMigrations } from './db/migrate' import { runMigrations } from './db/migrate'
import { testConnection } from './db/client' 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('🚀 Starting AiWorker Backend...')
console.log(`Bun version: ${Bun.version}`) console.log(`Bun version: ${Bun.version}`)
@@ -41,6 +41,11 @@ const server = Bun.serve({
return handleHealthCheck() return handleHealthCheck()
} }
// Auth routes
if (url.pathname.startsWith('/api/auth')) {
return handleAuthRoutes(req, url)
}
// API routes // API routes
if (url.pathname.startsWith('/api/projects')) { if (url.pathname.startsWith('/api/projects')) {
return handleProjectRoutes(req, url) return handleProjectRoutes(req, url)
@@ -61,6 +66,10 @@ const server = Bun.serve({
version: '1.0.0', version: '1.0.0',
endpoints: [ endpoints: [
'GET /api/health', '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',
'GET /api/projects/:id', 'GET /api/projects/:id',
'POST /api/projects', 'POST /api/projects',

37
test-auth.ts Normal file
View File

@@ -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()