Add user auth with JWT, switch to bun test

- User table with company_id FK, unique email, role enum
- Register/login routes with bcrypt + JWT token generation
- Auth plugin with authenticate decorator and role guards
- Login uses globally unique email (no company header needed)
- Dev-auth plugin kept as fallback when JWT_SECRET not set
- Switched from vitest to bun:test (vitest had ESM resolution
  issues with zod in Bun's module structure)
- Upgraded to zod 4
- Added Dockerfile.dev and API service to docker-compose
- 8 tests passing (health + auth)
This commit is contained in:
Ryan Moon
2026-03-27 17:33:05 -05:00
parent c1cddd6b74
commit 979a9a2c00
28 changed files with 1181 additions and 39 deletions

View File

@@ -0,0 +1,111 @@
import type { FastifyPluginAsync } from 'fastify'
import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt'
import { RegisterSchema, LoginSchema } from '@forte/shared/schemas'
import { users } from '../../db/schema/users.js'
const SALT_ROUNDS = 10
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/auth/register', async (request, reply) => {
const parsed = RegisterSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 },
})
}
const { email, password, firstName, lastName, role } = parsed.data
const companyId = request.companyId
// Email is globally unique across all companies
const existing = await app.db
.select({ id: users.id })
.from(users)
.where(eq(users.email, email))
.limit(1)
if (existing.length > 0) {
return reply.status(409).send({
error: { message: 'User with this email already exists', statusCode: 409 },
})
}
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
const [user] = await app.db
.insert(users)
.values({
companyId,
email,
passwordHash,
firstName,
lastName,
role,
})
.returning({
id: users.id,
email: users.email,
firstName: users.firstName,
lastName: users.lastName,
role: users.role,
createdAt: users.createdAt,
})
const token = app.jwt.sign({
id: user.id,
companyId,
role: user.role,
})
return reply.status(201).send({ user, token })
})
app.post('/auth/login', async (request, reply) => {
const parsed = LoginSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 },
})
}
const { email, password } = parsed.data
// Email is globally unique — company is derived from the user record
const [user] = await app.db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1)
if (!user) {
return reply.status(401).send({
error: { message: 'Invalid email or password', statusCode: 401 },
})
}
const valid = await bcrypt.compare(password, user.passwordHash)
if (!valid) {
return reply.status(401).send({
error: { message: 'Invalid email or password', statusCode: 401 },
})
}
const token = app.jwt.sign({
id: user.id,
companyId: user.companyId,
role: user.role,
})
return reply.send({
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
token,
})
})
}