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,62 @@
import fp from 'fastify-plugin'
import fjwt from '@fastify/jwt'
declare module 'fastify' {
interface FastifyRequest {
companyId: string
locationId: string
user: { id: string; companyId: string; role: string }
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { id: string; companyId: string; role: string }
user: { id: string; companyId: string; role: string }
}
}
export const authPlugin = fp(async (app) => {
const secret = process.env.JWT_SECRET
if (!secret) {
throw new Error('JWT_SECRET environment variable is required')
}
await app.register(fjwt, {
secret,
sign: { expiresIn: '24h' },
})
// Set companyId from header on all requests (for unauthenticated routes like register/login).
// Authenticated routes override this with the JWT payload via the authenticate decorator.
app.addHook('onRequest', async (request) => {
request.companyId = (request.headers['x-company-id'] as string) ?? ''
request.locationId = (request.headers['x-location-id'] as string) ?? ''
})
app.decorate('authenticate', async function (request: any, reply: any) {
try {
await request.jwtVerify()
request.companyId = request.user.companyId
} catch (_err) {
reply.status(401).send({ error: { message: 'Unauthorized', statusCode: 401 } })
}
})
app.decorate('requireRole', function (...roles: string[]) {
return async function (request: any, reply: any) {
if (!roles.includes(request.user.role)) {
reply
.status(403)
.send({ error: { message: 'Insufficient permissions', statusCode: 403 } })
}
}
})
})
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: any, reply: any) => Promise<void>
requireRole: (...roles: string[]) => (request: any, reply: any) => Promise<void>
}
}