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:
@@ -1 +1,2 @@
|
||||
export * from './schema/stores.js'
|
||||
export * from './schema/users.js'
|
||||
|
||||
14
packages/backend/src/db/migrations/0001_gray_lightspeed.sql
Normal file
14
packages/backend/src/db/migrations/0001_gray_lightspeed.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TYPE "public"."user_role" AS ENUM('admin', 'manager', 'staff', 'technician', 'instructor');--> statement-breakpoint
|
||||
CREATE TABLE "user" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"email" varchar(255) NOT NULL,
|
||||
"password_hash" varchar(255) NOT NULL,
|
||||
"first_name" varchar(100) NOT NULL,
|
||||
"last_name" varchar(100) NOT NULL,
|
||||
"role" "user_role" DEFAULT 'staff' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");
|
||||
273
packages/backend/src/db/migrations/meta/0001_snapshot.json
Normal file
273
packages/backend/src/db/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"id": "0f909014-3256-4320-9da2-a39a3b68671c",
|
||||
"prevId": "fd79ece0-66f3-4238-a2ca-af44060363b5",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "user_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'staff'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_company_id_company_id_fk": {
|
||||
"name": "user_company_id_company_id_fk",
|
||||
"tableFrom": "user",
|
||||
"tableTo": "company",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company": {
|
||||
"name": "company",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'America/Chicago'"
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.location": {
|
||||
"name": "location",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"location_company_id_company_id_fk": {
|
||||
"name": "location_company_id_company_id_fk",
|
||||
"tableFrom": "location",
|
||||
"tableTo": "company",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.user_role": {
|
||||
"name": "user_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"admin",
|
||||
"manager",
|
||||
"staff",
|
||||
"technician",
|
||||
"instructor"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
281
packages/backend/src/db/migrations/meta/0002_snapshot.json
Normal file
281
packages/backend/src/db/migrations/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"id": "69ee3c88-af63-4f16-88e1-15e4ce355a87",
|
||||
"prevId": "0f909014-3256-4320-9da2-a39a3b68671c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "user_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'staff'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_company_id_company_id_fk": {
|
||||
"name": "user_company_id_company_id_fk",
|
||||
"tableFrom": "user",
|
||||
"tableTo": "company",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company": {
|
||||
"name": "company",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'America/Chicago'"
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.location": {
|
||||
"name": "location",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timezone": {
|
||||
"name": "timezone",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"location_company_id_company_id_fk": {
|
||||
"name": "location_company_id_company_id_fk",
|
||||
"tableFrom": "location",
|
||||
"tableTo": "company",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.user_role": {
|
||||
"name": "user_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"admin",
|
||||
"manager",
|
||||
"staff",
|
||||
"technician",
|
||||
"instructor"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,20 @@
|
||||
"when": 1774635439354,
|
||||
"tag": "0000_hot_purifiers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1774646377107,
|
||||
"tag": "0001_gray_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1774648659531,
|
||||
"tag": "0002_bumpy_mandarin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
27
packages/backend/src/db/schema/users.ts
Normal file
27
packages/backend/src/db/schema/users.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { pgTable, uuid, varchar, timestamp, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core'
|
||||
import { companies } from './stores.js'
|
||||
|
||||
export const userRoleEnum = pgEnum('user_role', [
|
||||
'admin',
|
||||
'manager',
|
||||
'staff',
|
||||
'technician',
|
||||
'instructor',
|
||||
])
|
||||
|
||||
export const users = pgTable('user', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
firstName: varchar('first_name', { length: 100 }).notNull(),
|
||||
lastName: varchar('last_name', { length: 100 }).notNull(),
|
||||
role: userRoleEnum('role').notNull().default('staff'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export type User = typeof users.$inferSelect
|
||||
export type UserInsert = typeof users.$inferInsert
|
||||
@@ -3,8 +3,10 @@ import { databasePlugin } from './plugins/database.js'
|
||||
import { redisPlugin } from './plugins/redis.js'
|
||||
import { corsPlugin } from './plugins/cors.js'
|
||||
import { errorHandlerPlugin } from './plugins/error-handler.js'
|
||||
import { authPlugin } from './plugins/auth.js'
|
||||
import { devAuthPlugin } from './plugins/dev-auth.js'
|
||||
import { healthRoutes } from './routes/v1/health.js'
|
||||
import { authRoutes } from './routes/v1/auth.js'
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
@@ -20,10 +22,17 @@ export async function buildApp() {
|
||||
await app.register(errorHandlerPlugin)
|
||||
await app.register(databasePlugin)
|
||||
await app.register(redisPlugin)
|
||||
await app.register(devAuthPlugin)
|
||||
|
||||
// Auth — use JWT if secret is set, otherwise dev bypass
|
||||
if (process.env.JWT_SECRET) {
|
||||
await app.register(authPlugin)
|
||||
} else {
|
||||
await app.register(devAuthPlugin)
|
||||
}
|
||||
|
||||
// Routes
|
||||
await app.register(healthRoutes, { prefix: '/v1' })
|
||||
await app.register(authRoutes, { prefix: '/v1' })
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -42,4 +51,7 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
start()
|
||||
// Only auto-start when not imported by tests
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
start()
|
||||
}
|
||||
|
||||
62
packages/backend/src/plugins/auth.ts
Normal file
62
packages/backend/src/plugins/auth.ts
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import * as schema from '../db/schema/stores.js'
|
||||
import * as storeSchema from '../db/schema/stores.js'
|
||||
import * as userSchema from '../db/schema/users.js'
|
||||
|
||||
const schema = { ...storeSchema, ...userSchema }
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
|
||||
@@ -4,27 +4,39 @@ declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
companyId: string
|
||||
locationId: string
|
||||
user: { id: string; role: string }
|
||||
user: { id: string; companyId: string; role: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only auth bypass. Reads headers to set request context
|
||||
* without real JWT validation.
|
||||
*
|
||||
* Will be replaced by real JWT auth in Phase 2.
|
||||
* Dev-only auth bypass. Used when JWT_SECRET is not set.
|
||||
* Reads x-company-id and x-location-id headers to set request context.
|
||||
*/
|
||||
export const devAuthPlugin = fp(async (app) => {
|
||||
app.addHook('onRequest', async (request) => {
|
||||
const companyId = request.headers['x-dev-company'] as string | undefined
|
||||
const locationId = request.headers['x-dev-location'] as string | undefined
|
||||
const userId = request.headers['x-dev-user'] as string | undefined
|
||||
const companyId = (request.headers['x-company-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
|
||||
const locationId = (request.headers['x-location-id'] as string) ?? '00000000-0000-0000-0000-000000000010'
|
||||
const userId = (request.headers['x-user-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
request.companyId = companyId ?? '00000000-0000-0000-0000-000000000001'
|
||||
request.locationId = locationId ?? '00000000-0000-0000-0000-000000000010'
|
||||
request.companyId = companyId
|
||||
request.locationId = locationId
|
||||
request.user = {
|
||||
id: userId ?? '00000000-0000-0000-0000-000000000001',
|
||||
id: userId,
|
||||
companyId,
|
||||
role: 'admin',
|
||||
}
|
||||
})
|
||||
|
||||
// No-op decorators so routes that use authenticate/requireRole still work
|
||||
app.decorate('authenticate', async function () {})
|
||||
app.decorate('requireRole', function () {
|
||||
return async function () {}
|
||||
})
|
||||
})
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: any, reply: any) => Promise<void>
|
||||
requireRole: (...roles: string[]) => (request: any, reply: any) => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
166
packages/backend/src/routes/v1/auth.test.ts
Normal file
166
packages/backend/src/routes/v1/auth.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createTestApp, cleanDb, seedTestCompany, TEST_COMPANY_ID } from '../../test/helpers.js'
|
||||
|
||||
describe('Auth routes', () => {
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanDb(app)
|
||||
await seedTestCompany(app)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
describe('POST /v1/auth/register', () => {
|
||||
it('creates a user and returns token', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'staff@musicstore.com',
|
||||
password: 'securepassword',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
role: 'staff',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(201)
|
||||
const body = response.json()
|
||||
expect(body.user.email).toBe('staff@musicstore.com')
|
||||
expect(body.user.firstName).toBe('Jane')
|
||||
expect(body.user.role).toBe('staff')
|
||||
expect(body.token).toBeDefined()
|
||||
expect(body.user.passwordHash).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects duplicate email within same company', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'dupe@test.com',
|
||||
password: 'password123',
|
||||
firstName: 'First',
|
||||
lastName: 'User',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'dupe@test.com',
|
||||
password: 'password456',
|
||||
firstName: 'Second',
|
||||
lastName: 'User',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(409)
|
||||
})
|
||||
|
||||
it('rejects invalid email', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'not-an-email',
|
||||
password: 'password123',
|
||||
firstName: 'Bad',
|
||||
lastName: 'Email',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects short password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'short@test.com',
|
||||
password: '123',
|
||||
firstName: 'Short',
|
||||
lastName: 'Pass',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /v1/auth/login', () => {
|
||||
beforeEach(async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'login@test.com',
|
||||
password: 'correctpassword',
|
||||
firstName: 'Login',
|
||||
lastName: 'User',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns token with valid credentials', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/login',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'login@test.com',
|
||||
password: 'correctpassword',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
const body = response.json()
|
||||
expect(body.token).toBeDefined()
|
||||
expect(body.user.email).toBe('login@test.com')
|
||||
})
|
||||
|
||||
it('rejects wrong password', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/login',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'login@test.com',
|
||||
password: 'wrongpassword',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects nonexistent email', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/login',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: 'nobody@test.com',
|
||||
password: 'whatever',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
})
|
||||
111
packages/backend/src/routes/v1/auth.ts
Normal file
111
packages/backend/src/routes/v1/auth.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createTestApp } from '../../test/helpers.js'
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { buildApp } from '../main.js'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { companies, locations } from '../db/schema/stores.js'
|
||||
|
||||
export const TEST_COMPANY_ID = '00000000-0000-0000-0000-000000000099'
|
||||
export const TEST_LOCATION_ID = '00000000-0000-0000-0000-000000000099'
|
||||
|
||||
/**
|
||||
* Build a fresh Fastify app instance for testing.
|
||||
* Each test gets its own app — no shared state.
|
||||
*/
|
||||
export async function createTestApp(): Promise<FastifyInstance> {
|
||||
const app = await buildApp()
|
||||
@@ -14,7 +17,6 @@ export async function createTestApp(): Promise<FastifyInstance> {
|
||||
|
||||
/**
|
||||
* Truncate all tables in the test database.
|
||||
* Call this in beforeEach to guarantee a clean slate per test.
|
||||
*/
|
||||
export async function cleanDb(app: FastifyInstance): Promise<void> {
|
||||
await app.db.execute(sql`
|
||||
@@ -27,3 +29,49 @@ export async function cleanDb(app: FastifyInstance): Promise<void> {
|
||||
END $$
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a test company and location. Call after cleanDb.
|
||||
*/
|
||||
export async function seedTestCompany(app: FastifyInstance): Promise<void> {
|
||||
await app.db.insert(companies).values({
|
||||
id: TEST_COMPANY_ID,
|
||||
name: 'Test Music Co.',
|
||||
timezone: 'America/Chicago',
|
||||
})
|
||||
await app.db.insert(locations).values({
|
||||
id: TEST_LOCATION_ID,
|
||||
companyId: TEST_COMPANY_ID,
|
||||
name: 'Test Location',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a user and return the JWT token.
|
||||
*/
|
||||
export async function registerAndLogin(
|
||||
app: FastifyInstance,
|
||||
overrides: {
|
||||
email?: string
|
||||
password?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
role?: string
|
||||
} = {},
|
||||
): Promise<{ token: string; user: Record<string, unknown> }> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/register',
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: overrides.email ?? 'test@forte.dev',
|
||||
password: overrides.password ?? 'testpassword123',
|
||||
firstName: overrides.firstName ?? 'Test',
|
||||
lastName: overrides.lastName ?? 'User',
|
||||
role: overrides.role ?? 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
const body = response.json()
|
||||
return { token: body.token, user: body.user }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const TEST_DB_URL =
|
||||
process.env.DATABASE_URL = TEST_DB_URL
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.LOG_LEVEL = 'silent'
|
||||
process.env.JWT_SECRET = 'test-secret-for-jwt-signing'
|
||||
|
||||
/**
|
||||
* Ensure the forte_test database exists before tests run.
|
||||
|
||||
Reference in New Issue
Block a user