From ff5164c7b786a5c877f18967335fe0a1304fb472 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 05:46:20 +0000 Subject: [PATCH 01/22] Apply security hardening across authentication, access control, and observability Critical/High fixes: - Protect /metrics and /health/details with fastify.authenticate - Add per-route rate limits (5/15min) on login, register, and mobile login - Remove dev bypass from rate limiting; keep test-only skip - Fix XFF key generator to parse first IP from forwarded chain - Add product RBAC: product:create/update/delete:any permissions required for mutations - Use crypto.timingSafeEqual for mobile API key comparison (prevents timing attack) - Validate x-request-id header against safe pattern before echoing (prevents header injection) - Replace CORS placeholder origin with CORS_ORIGIN env var; restrict non-production to localhost Medium/Low fixes: - Remove email PII from all audit log metadata; log changedFields keys only - Generalize registration conflict message to prevent account enumeration - Normalize bcrypt timing in registerUser to prevent soft-delete oracle - Add clearCookie security flags (httpOnly, secure, sameSite) on logout - Enforce COOKIE_SECRET in production; fail fast if not set - Fix plugin registration order: db before rate-limit, metrics after auth decorator - Make admin role a system role (isSystemRole: true) to prevent deletion - Explicit field list in updateRole to prevent latent mass-assignment - Add password complexity requirement (uppercase, lowercase, digit) - Add index on audit_logs.user_id + migration 0008 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S3NT5TALAVtn916JREYXwQ --- .env.example | 3 + migrations/0008_audit_logs_user_id_idx.sql | 1 + migrations/meta/0008_snapshot.json | 579 +++++++++++++++++++++ migrations/meta/_journal.json | 9 +- src/app.ts | 8 +- src/common/constants/index.ts | 6 + src/common/hooks/request-id.ts | 5 +- src/config/schema.ts | 2 + src/contract/schemas/auth.ts | 3 + src/contract/schemas/products.ts | 9 +- src/contract/types.ts | 1 + src/db/schema/audit-logs.ts | 6 +- src/db/seed.ts | 12 +- src/modules/auth/routes/index.ts | 16 +- src/modules/auth/schemas/index.ts | 4 +- src/modules/auth/services/auth.service.ts | 18 +- src/modules/health/routes/index.ts | 3 + src/modules/roles/services/role.service.ts | 5 +- src/modules/users/routes/index.ts | 4 +- src/plugins/cookie.ts | 6 +- src/plugins/cors.ts | 10 +- src/plugins/metrics.ts | 1 + src/plugins/rate-limit.ts | 14 +- src/plugins/rpc.ts | 1 + 24 files changed, 692 insertions(+), 34 deletions(-) create mode 100644 migrations/0008_audit_logs_user_id_idx.sql create mode 100644 migrations/meta/0008_snapshot.json diff --git a/.env.example b/.env.example index d85c950..18273ba 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ OTEL_ENDPOINT= LOG_LEVEL=info # Required — shared secret for mobile clients; generate with: openssl rand -hex 32 MOBILE_API_KEY=change_this_to_a_random_string_at_least_32_characters +# Required in production — comma-separated list of allowed CORS origins +# Example: CORS_ORIGIN=https://app.example.com,https://admin.example.com +CORS_ORIGIN= diff --git a/migrations/0008_audit_logs_user_id_idx.sql b/migrations/0008_audit_logs_user_id_idx.sql new file mode 100644 index 0000000..809b5cd --- /dev/null +++ b/migrations/0008_audit_logs_user_id_idx.sql @@ -0,0 +1 @@ +CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs" ("user_id"); diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..368ee26 --- /dev/null +++ b/migrations/meta/0008_snapshot.json @@ -0,0 +1,579 @@ +{ + "id": "84d08f40-1e00-42d1-8275-a5273c528bd2", + "prevId": "f88fd5ad-2028-4215-9d83-afbed5ac55b1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_resource_action_scope_unique": { + "name": "permissions_resource_action_scope_unique", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "stock": { + "name": "stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "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": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_user_id_unique": { + "name": "profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system_role": { + "name": "is_system_role", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 9be704e..67de308 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1782398877539, "tag": "0007_wet_victor_mancha", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1751030400000, + "tag": "0008_audit_logs_user_id_idx", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app.ts b/src/app.ts index 87bc82f..1bc05dd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -65,8 +65,8 @@ export async function buildApp() { // Data layer await fastify.register(redisPlugin) - await fastify.register(rateLimitPlugin) await fastify.register(dbPlugin) + await fastify.register(rateLimitPlugin) // Reliability await fastify.register(underPressurePlugin) @@ -75,14 +75,14 @@ export async function buildApp() { await fastify.register(multipartPlugin) await fastify.register(requestContextPlugin) - // Observability - await fastify.register(metricsPlugin) - // Auth await fastify.register(jwtPlugin) await fastify.register(authDecorator) await fastify.register(requestIdHook) + // Observability — registered after auth so fastify.authenticate is available + await fastify.register(metricsPlugin) + // Global error handler fastify.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { diff --git a/src/common/constants/index.ts b/src/common/constants/index.ts index 2eb8fc1..488bc5b 100644 --- a/src/common/constants/index.ts +++ b/src/common/constants/index.ts @@ -27,6 +27,12 @@ export const PERMISSIONS = { PERMISSION: { READ_ANY: 'permission:read:any', }, + PRODUCT: { + READ_ANY: 'product:read:any', + CREATE_ANY: 'product:create:any', + UPDATE_ANY: 'product:update:any', + DELETE_ANY: 'product:delete:any', + }, AUDIT_LOG: { READ_ANY: 'audit-log:read:any', }, diff --git a/src/common/hooks/request-id.ts b/src/common/hooks/request-id.ts index c68bdbe..9e21745 100644 --- a/src/common/hooks/request-id.ts +++ b/src/common/hooks/request-id.ts @@ -2,9 +2,12 @@ import type { FastifyPluginAsync } from 'fastify' import { randomUUID } from 'node:crypto' import fp from 'fastify-plugin' +const REQUEST_ID_PATTERN = /^[\w\-]{1,64}$/ + const requestIdHook: FastifyPluginAsync = async (fastify) => { fastify.addHook('onRequest', async (request, reply) => { - const id = (request.headers['x-request-id'] as string) ?? randomUUID() + const raw = request.headers['x-request-id'] as string | undefined + const id = raw && REQUEST_ID_PATTERN.test(raw) ? raw : randomUUID() reply.header('x-request-id', id) request.requestContext.set('requestId', id) }) diff --git a/src/config/schema.ts b/src/config/schema.ts index ebddf8d..24c2f2d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -16,6 +16,7 @@ export interface AppConfig { COOKIE_SECRET: string OTEL_ENDPOINT: string MOBILE_API_KEY: string + CORS_ORIGIN: string } export const configSchema = { @@ -37,5 +38,6 @@ export const configSchema = { COOKIE_SECRET: { type: 'string', default: '' }, OTEL_ENDPOINT: { type: 'string', default: '' }, MOBILE_API_KEY: { type: 'string' }, + CORS_ORIGIN: { type: 'string', default: '' }, }, } as const diff --git a/src/contract/schemas/auth.ts b/src/contract/schemas/auth.ts index c9a3579..b3dd6d7 100644 --- a/src/contract/schemas/auth.ts +++ b/src/contract/schemas/auth.ts @@ -13,6 +13,7 @@ export const authSchema = { method: 'POST' as const, path: '/api/v1/auth/register', tags: ['Auth'], + rateLimit: { max: 5, timeWindow: '15 minutes' }, body: registerBodySchema, responses: { 201: apiSuccessSchema(authUserSchema), @@ -23,6 +24,7 @@ export const authSchema = { method: 'POST' as const, path: '/api/v1/auth/login', tags: ['Auth'], + rateLimit: { max: 5, timeWindow: '15 minutes' }, body: loginBodySchema, responses: { 200: apiSuccessSchema(authUserSchema), @@ -33,6 +35,7 @@ export const authSchema = { method: 'POST' as const, path: '/api/v1/auth/mobile/login', tags: ['Auth'], + rateLimit: { max: 5, timeWindow: '15 minutes' }, body: loginBodySchema, responses: { 200: apiSuccessSchema(loginResponseSchema), diff --git a/src/contract/schemas/products.ts b/src/contract/schemas/products.ts index b065691..42b7ee3 100644 --- a/src/contract/schemas/products.ts +++ b/src/contract/schemas/products.ts @@ -35,23 +35,25 @@ export const productsSchema = { method: 'POST' as const, path: '/api/v1/products', tags: ['Products'], - auth: true, + permission: 'product:create:any', body: createProductBodySchema, responses: { 201: apiSuccessSchema(productSchema), 401: apiErrorSchema, + 403: apiErrorSchema, }, }, update: { method: 'PATCH' as const, path: '/api/v1/products/:id', tags: ['Products'], - auth: true, + permission: 'product:update:any', params: uuidParamSchema, body: updateProductBodySchema, responses: { 200: apiSuccessSchema(productSchema), 401: apiErrorSchema, + 403: apiErrorSchema, 404: apiErrorSchema, }, }, @@ -59,11 +61,12 @@ export const productsSchema = { method: 'DELETE' as const, path: '/api/v1/products/:id', tags: ['Products'], - auth: true, + permission: 'product:delete:any', params: uuidParamSchema, responses: { 204: z.null(), 401: apiErrorSchema, + 403: apiErrorSchema, 404: apiErrorSchema, }, }, diff --git a/src/contract/types.ts b/src/contract/types.ts index c31bb17..2d5666c 100644 --- a/src/contract/types.ts +++ b/src/contract/types.ts @@ -26,6 +26,7 @@ export type RouteMap = Record [ + index('audit_logs_user_id_idx').on(t.userId), +]) export type AuditLogRow = typeof auditLogs.$inferSelect export type NewAuditLogRow = typeof auditLogs.$inferInsert diff --git a/src/db/seed.ts b/src/db/seed.ts index bd00d2e..2ae4874 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -6,7 +6,7 @@ import { permissions, rolePermissions, roles } from '@/db/schema/index.js' const SEED_ROLES = [ { name: 'super-admin', description: 'Full system access', isSystemRole: true }, - { name: 'admin', description: 'Administrative access', isSystemRole: false }, + { name: 'admin', description: 'Administrative access', isSystemRole: true }, { name: 'user', description: 'Standard user access', isSystemRole: false }, ] @@ -25,6 +25,10 @@ const SEED_PERMISSIONS = [ { resource: 'permission', action: 'read', scope: 'any' }, { resource: 'permission', action: 'update', scope: 'any' }, { resource: 'permission', action: 'delete', scope: 'any' }, + { resource: 'product', action: 'read', scope: 'any' }, + { resource: 'product', action: 'create', scope: 'any' }, + { resource: 'product', action: 'update', scope: 'any' }, + { resource: 'product', action: 'delete', scope: 'any' }, { resource: 'audit-log', action: 'read', scope: 'any' }, ] @@ -39,9 +43,13 @@ const ROLE_PERMISSIONS: Record = { 'user:update:own', 'role:read:any', 'permission:read:any', + 'product:read:any', + 'product:create:any', + 'product:update:any', + 'product:delete:any', 'audit-log:read:any', ], - 'user': ['user:read:own', 'user:update:own'], + 'user': ['user:read:own', 'user:update:own', 'product:read:any'], } export async function seedRoles(db: Db) { diff --git a/src/modules/auth/routes/index.ts b/src/modules/auth/routes/index.ts index e893899..6b7cc73 100644 --- a/src/modules/auth/routes/index.ts +++ b/src/modules/auth/routes/index.ts @@ -1,4 +1,5 @@ import type { FastifyReply } from 'fastify' +import { timingSafeEqual } from 'node:crypto' import { ForbiddenError } from '@/common/errors/AppError.js' import { authSchema } from '@/contract/schemas/auth.js' import { logAudit } from '@/modules/audit-logs/helpers/log-audit.js' @@ -24,23 +25,30 @@ export default createFastifyRpcPlugin(authSchema, { secure: request.server.config.NODE_ENV === 'production', sameSite: 'strict', }) - logAudit(request.server.db, { userId: user.id, action: 'auth.logged_in', resourceType: 'user', resourceId: user.id, metadata: { email: user.email, ip: request.ip, ua: request.headers['user-agent'] ?? null } }) + logAudit(request.server.db, { userId: user.id, action: 'auth.logged_in', resourceType: 'user', resourceId: user.id, metadata: { ip: request.ip, ua: request.headers['user-agent'] ?? null } }) return { status: 200 as const, body: { success: true as const, data: { id: user.id, email: user.email } } } }, mobileLogin: async ({ body, request, reply }) => { - if (request.headers['x-mobile-api-key'] !== request.server.config.MOBILE_API_KEY) + const provided = Buffer.from(request.headers['x-mobile-api-key'] as string ?? '') + const expected = Buffer.from(request.server.config.MOBILE_API_KEY) + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) throw new ForbiddenError('Mobile login is restricted to mobile clients') const user = await authService.loginUser(request.server.db, body) const token = await signToken(user, reply) - logAudit(request.server.db, { userId: user.id, action: 'auth.logged_in', resourceType: 'user', resourceId: user.id, metadata: { email: user.email, ip: request.ip, ua: request.headers['user-agent'] ?? null } }) + logAudit(request.server.db, { userId: user.id, action: 'auth.logged_in', resourceType: 'user', resourceId: user.id, metadata: { ip: request.ip, ua: request.headers['user-agent'] ?? null } }) return { status: 200 as const, body: { success: true as const, data: { id: user.id, email: user.email, token } } } }, logout: async ({ request, reply }) => { const userId = request.requestContext.get('userId') ?? null logAudit(request.server.db, { userId, action: 'auth.logged_out', resourceType: userId ? 'user' : 'anonymous', resourceId: userId, metadata: { ip: request.ip, ua: request.headers['user-agent'] ?? null } }) - reply.clearCookie('token', { path: '/' }) + reply.clearCookie('token', { + path: '/', + httpOnly: true, + secure: request.server.config.NODE_ENV === 'production', + sameSite: 'strict', + }) return { status: 200 as const, body: { success: true as const, data: null } } }, }) diff --git a/src/modules/auth/schemas/index.ts b/src/modules/auth/schemas/index.ts index 9cf5e28..5e7bddb 100644 --- a/src/modules/auth/schemas/index.ts +++ b/src/modules/auth/schemas/index.ts @@ -2,7 +2,9 @@ import { z } from 'zod' export const registerBodySchema = z.object({ email: z.email().meta({ examples: ['alice@example.com'] }), - password: z.string().min(8).max(72).meta({ examples: ['securepassword123'] }), + password: z.string().min(8).max(72) + .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Password must contain at least one uppercase letter, one lowercase letter, and one number') + .meta({ examples: ['SecurePassword1'] }), }) export const loginBodySchema = z.object({ diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 3b2afdf..ec35036 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -7,13 +7,18 @@ import { ConflictError, UnauthorizedError } from '@/common/errors/AppError.js' import { profiles, roles, userRoles, users } from '@/db/schema/index.js' import { logAudit } from '@/modules/audit-logs/helpers/log-audit.js' +// Constant-time dummy hash used to normalize timing when no dead account exists, +// preventing a timing oracle that distinguishes "email never registered" from +// "email soft-deleted" via the presence/absence of a bcrypt comparison. +const DUMMY_HASH = '$2b$12$Dn0etVDxKuYEzAYFGnb4hO3dJ3P1bLnRMOqFqDjNYr2JHM1vvD7Ui' + export async function registerUser(db: Db, body: RegisterBody) { // An email may have at most one active row but several soft-deleted rows // (the partial unique index only covers deletedAt IS NULL), so the conflict // check must be scoped to active rows specifically. const active = await db.query.users.findFirst({ where: and(eq(users.email, body.email), isNull(users.deletedAt)) }) if (active) - throw new ConflictError(`Email '${body.email}' is already registered`) + throw new ConflictError('An account with this email already exists') // Reactivate a soft-deleted account if the password matches (within the 90-day // window before the cleanup cron hard-deletes it). Profile row still exists, so @@ -25,12 +30,15 @@ export async function registerUser(db: Db, body: RegisterBody) { const [userRole] = await db.select({ id: roles.id }).from(roles).where(eq(roles.name, 'user')).limit(1) if (userRole) await db.insert(userRoles).values({ userId: dead.id, roleId: userRole.id }).onConflictDoNothing() - logAudit(db, { userId: dead.id, action: 'auth.account_restored', resourceType: 'user', resourceId: dead.id, metadata: { email: dead.email } }) + logAudit(db, { userId: dead.id, action: 'auth.account_restored', resourceType: 'user', resourceId: dead.id }) return { id: dead.id, email: dead.email } } - throw new ConflictError(`Email '${body.email}' is already registered`) + throw new ConflictError('An account with this email already exists') } + // Normalize timing vs the dead-account path above. + await bcrypt.compare(body.password, DUMMY_HASH) + const passwordHash = await bcrypt.hash(body.password, 12) try { @@ -49,7 +57,7 @@ export async function registerUser(db: Db, body: RegisterBody) { return row }) - logAudit(db, { userId: user.id, action: 'auth.registered', resourceType: 'user', resourceId: user.id, metadata: { email: user.email } }) + logAudit(db, { userId: user.id, action: 'auth.registered', resourceType: 'user', resourceId: user.id }) return user } catch (err) { @@ -57,7 +65,7 @@ export async function registerUser(db: Db, body: RegisterBody) { // check above; the partial unique index rejects the loser with 23505. const pgCode = (err as { cause?: { code?: string } })?.cause?.code if (pgCode === PG_UNIQUE_VIOLATION) - throw new ConflictError(`Email '${body.email}' is already registered`) + throw new ConflictError('An account with this email already exists') throw err } } diff --git a/src/modules/health/routes/index.ts b/src/modules/health/routes/index.ts index 0d7ccca..f91953b 100644 --- a/src/modules/health/routes/index.ts +++ b/src/modules/health/routes/index.ts @@ -31,6 +31,7 @@ const healthRoutes: FastifyPluginAsyncZod = async (fastify) => { schema: { tags: ['Health'], summary: 'System details — memory, event loop, pressure status', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], response: { 200: apiSuccessSchema(z.object({ status: z.string(), @@ -42,8 +43,10 @@ const healthRoutes: FastifyPluginAsyncZod = async (fastify) => { }), underPressure: z.boolean(), })), + 401: apiErrorSchema, }, }, + preValidation: [fastify.authenticate], handler: controller.details, }) } diff --git a/src/modules/roles/services/role.service.ts b/src/modules/roles/services/role.service.ts index 1332902..a872e9f 100644 --- a/src/modules/roles/services/role.service.ts +++ b/src/modules/roles/services/role.service.ts @@ -51,7 +51,10 @@ export async function updateRole(db: Db, id: string, body: UpdateRoleBody, calle if (existing.isSystemRole && body.name !== undefined && body.name !== existing.name) throw new ForbiddenError('System role names cannot be changed') try { - const [row] = await db.update(roles).set(body).where(eq(roles.id, id)).returning() + const [row] = await db.update(roles).set({ + ...(body.name !== undefined && { name: body.name }), + ...(body.description !== undefined && { description: body.description }), + }).where(eq(roles.id, id)).returning() if (!row) throw new NotFoundError('Role', id) return toRole(row) diff --git a/src/modules/users/routes/index.ts b/src/modules/users/routes/index.ts index 6cc61c7..a0e8d27 100644 --- a/src/modules/users/routes/index.ts +++ b/src/modules/users/routes/index.ts @@ -31,14 +31,14 @@ export default createFastifyRpcPlugin(usersSchema, { create: async ({ body, request }) => { const user = await userService.createUser(request.server.db, body) - logAudit(request.server.db, { userId: request.requestContext.get('userId'), action: 'user.created', resourceType: 'user', resourceId: user.id, metadata: { email: user.email } }) + logAudit(request.server.db, { userId: request.requestContext.get('userId'), action: 'user.created', resourceType: 'user', resourceId: user.id }) return { status: 201 as const, body: { success: true as const, data: user } } }, update: async ({ params, body, request }) => { assertSelfOrAdmin(request, params.id, PERMISSIONS.USER.UPDATE_ANY) const user = await userService.updateUser(request.server.db, params.id, body) - logAudit(request.server.db, { userId: request.requestContext.get('userId'), action: 'user.updated', resourceType: 'user', resourceId: params.id, metadata: { changes: body } }) + logAudit(request.server.db, { userId: request.requestContext.get('userId'), action: 'user.updated', resourceType: 'user', resourceId: params.id, metadata: { changedFields: Object.keys(body) } }) return { status: 200 as const, body: { success: true as const, data: user } } }, diff --git a/src/plugins/cookie.ts b/src/plugins/cookie.ts index cb822ca..f42ce42 100644 --- a/src/plugins/cookie.ts +++ b/src/plugins/cookie.ts @@ -3,9 +3,11 @@ import cookie from '@fastify/cookie' import fp from 'fastify-plugin' const cookiePlugin: FastifyPluginAsync = async (fastify) => { + if (fastify.config.NODE_ENV === 'production' && !fastify.config.COOKIE_SECRET) { + throw new Error('COOKIE_SECRET must be set in production to isolate cookie signing from JWT signing') + } + await fastify.register(cookie, { - // Falls back to JWT_SECRET so the app works without a separate COOKIE_SECRET. - // Set COOKIE_SECRET in prod to isolate cookie signing from JWT signing. secret: fastify.config.COOKIE_SECRET || fastify.config.JWT_SECRET, parseOptions: { httpOnly: true, diff --git a/src/plugins/cors.ts b/src/plugins/cors.ts index 49a6b97..524428e 100644 --- a/src/plugins/cors.ts +++ b/src/plugins/cors.ts @@ -3,8 +3,16 @@ import cors from '@fastify/cors' import fp from 'fastify-plugin' const corsPlugin: FastifyPluginAsync = async (fastify) => { + const configuredOrigins = fastify.config.CORS_ORIGIN + ? fastify.config.CORS_ORIGIN.split(',').map(s => s.trim()).filter(Boolean) + : [] + + const origin = fastify.config.NODE_ENV === 'production' + ? configuredOrigins + : ['http://localhost:3000', 'http://localhost:5173'] + await fastify.register(cors, { - origin: fastify.config.NODE_ENV === 'production' ? ['https://yourdomain.com'] : true, + origin, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, diff --git a/src/plugins/metrics.ts b/src/plugins/metrics.ts index ba03ae2..5e2236c 100644 --- a/src/plugins/metrics.ts +++ b/src/plugins/metrics.ts @@ -17,6 +17,7 @@ const metricsPlugin: FastifyPluginAsync = async (fastify) => { fastify.get('/metrics', { schema: { hide: true }, + preValidation: [fastify.authenticate], handler: async (_request, reply) => { reply.header('Content-Type', registry.contentType) return registry.metrics() diff --git a/src/plugins/rate-limit.ts b/src/plugins/rate-limit.ts index 741d9f4..79b92f6 100644 --- a/src/plugins/rate-limit.ts +++ b/src/plugins/rate-limit.ts @@ -3,8 +3,9 @@ import rateLimit from '@fastify/rate-limit' import fp from 'fastify-plugin' const rateLimitPlugin: FastifyPluginAsync = async (fastify) => { - // Skip in dev — hot reloads would exhaust the window constantly. - if (fastify.config.NODE_ENV === 'development') + // Skip only in test — rate limits would break integration tests that hit + // auth endpoints many times and use real Redis with shared counters. + if (fastify.config.NODE_ENV === 'test') return await fastify.register(rateLimit, { @@ -12,10 +13,13 @@ const rateLimitPlugin: FastifyPluginAsync = async (fastify) => { timeWindow: '15 minutes', allowList: ['127.0.0.1'], redis: fastify.redis, - // x-forwarded-for first so clients behind a reverse proxy are keyed by - // their real IP, not the proxy's. allowList is the real spoofing defence. + // Parse the leftmost entry from x-forwarded-for so clients behind a trusted + // reverse proxy are keyed by their real IP. Do not trust the full header + // string as a key — a single client can send multiple IPs in the chain. keyGenerator: (request) => { - return request.headers['x-forwarded-for'] as string ?? request.ip + const xff = request.headers['x-forwarded-for'] + const raw = Array.isArray(xff) ? xff[0] : xff + return raw?.split(',')[0]?.trim() ?? request.ip }, }) } diff --git a/src/plugins/rpc.ts b/src/plugins/rpc.ts index 6fb2887..b1cffb4 100644 --- a/src/plugins/rpc.ts +++ b/src/plugins/rpc.ts @@ -42,6 +42,7 @@ export function createFastifyRpcPlugin( fastify.route({ method: route.method as any, url: route.path, + ...(route.rateLimit !== undefined && { config: { rateLimit: route.rateLimit } }), schema: { ...(route.tags !== undefined && { tags: route.tags }), ...((route.auth || route.permission) && { security: [{ cookieAuth: [] }, { bearerAuth: [] }] }), From 3266767c41a6eb49ee562d4815c96e897e62deb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 09:12:36 +0000 Subject: [PATCH 02/22] Address PR review comments: shared password policy and admin role backfill migration - Extract password complexity (uppercase + lowercase + digit, 8-72 chars) into a shared passwordSchema in src/common/schemas/index.ts so both the self-registration path (/auth/register) and the admin-created user path (/api/v1/users) enforce the same policy - Add migration 0009 to UPDATE roles SET is_system_role = true WHERE name = 'admin' so existing databases that already have the admin row get backfilled correctly; the previous onConflictDoNothing seed would have left the old value in place Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S3NT5TALAVtn916JREYXwQ --- .../0009_backfill_admin_system_role.sql | 1 + migrations/meta/0009_snapshot.json | 579 ++++++++++++++++++ migrations/meta/_journal.json | 7 + src/common/schemas/index.ts | 8 + src/modules/auth/schemas/index.ts | 5 +- src/modules/users/schemas/index.ts | 4 +- 6 files changed, 599 insertions(+), 5 deletions(-) create mode 100644 migrations/0009_backfill_admin_system_role.sql create mode 100644 migrations/meta/0009_snapshot.json diff --git a/migrations/0009_backfill_admin_system_role.sql b/migrations/0009_backfill_admin_system_role.sql new file mode 100644 index 0000000..3ca08f6 --- /dev/null +++ b/migrations/0009_backfill_admin_system_role.sql @@ -0,0 +1 @@ +UPDATE "roles" SET "is_system_role" = true WHERE "name" = 'admin'; diff --git a/migrations/meta/0009_snapshot.json b/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..aaad50d --- /dev/null +++ b/migrations/meta/0009_snapshot.json @@ -0,0 +1,579 @@ +{ + "id": "4d3758db-8b41-488e-a3b9-dbe5ad18275d", + "prevId": "84d08f40-1e00-42d1-8275-a5273c528bd2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_resource_action_scope_unique": { + "name": "permissions_resource_action_scope_unique", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "stock": { + "name": "stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "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": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_user_id_unique": { + "name": "profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system_role": { + "name": "is_system_role", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 67de308..612eb30 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1751030400000, "tag": "0008_audit_logs_user_id_idx", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1751034000000, + "tag": "0009_backfill_admin_system_role", + "breakpoints": true } ] } diff --git a/src/common/schemas/index.ts b/src/common/schemas/index.ts index f79c71a..2ebe2a2 100644 --- a/src/common/schemas/index.ts +++ b/src/common/schemas/index.ts @@ -1,5 +1,13 @@ import { z } from 'zod' +export const passwordSchema = z.string() + .min(8) + .max(72) + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, + 'Password must contain at least one uppercase letter, one lowercase letter, and one number', + ) + export const paginationQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1).meta({ examples: [1] }), limit: z.coerce.number().int().min(1).max(100).default(10).meta({ examples: [10] }), diff --git a/src/modules/auth/schemas/index.ts b/src/modules/auth/schemas/index.ts index 5e7bddb..37401cf 100644 --- a/src/modules/auth/schemas/index.ts +++ b/src/modules/auth/schemas/index.ts @@ -1,10 +1,9 @@ import { z } from 'zod' +import { passwordSchema } from '@/common/schemas/index.js' export const registerBodySchema = z.object({ email: z.email().meta({ examples: ['alice@example.com'] }), - password: z.string().min(8).max(72) - .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Password must contain at least one uppercase letter, one lowercase letter, and one number') - .meta({ examples: ['SecurePassword1'] }), + password: passwordSchema.meta({ examples: ['SecurePassword1'] }), }) export const loginBodySchema = z.object({ diff --git a/src/modules/users/schemas/index.ts b/src/modules/users/schemas/index.ts index 97f36ae..dad6425 100644 --- a/src/modules/users/schemas/index.ts +++ b/src/modules/users/schemas/index.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { paginationQuerySchema, uuidParamSchema } from '@/common/schemas/index.js' +import { paginationQuerySchema, passwordSchema, uuidParamSchema } from '@/common/schemas/index.js' export const profileSchema = z.object({ firstName: z.string().nullable().meta({ examples: ['John'] }), @@ -26,7 +26,7 @@ export const userSchema = z.object({ export const createUserBodySchema = z.object({ email: z.email().meta({ examples: ['user@example.com'] }), - password: z.string().min(8).max(72).meta({ examples: ['securepassword123'] }), + password: passwordSchema.meta({ examples: ['SecurePassword1'] }), }) export const updateProfileBodySchema = z.object({ From 8349cad6a3572cabba8d6b5ea85db5acd4ac9204 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 11:36:22 +0000 Subject: [PATCH 03/22] Fix ESLint errors: import Buffer from node:buffer, fix import order in users test Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S3NT5TALAVtn916JREYXwQ --- src/modules/auth/routes/index.ts | 1 + src/tests/modules/users.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/auth/routes/index.ts b/src/modules/auth/routes/index.ts index 6b7cc73..b1a6144 100644 --- a/src/modules/auth/routes/index.ts +++ b/src/modules/auth/routes/index.ts @@ -1,4 +1,5 @@ import type { FastifyReply } from 'fastify' +import { Buffer } from 'node:buffer' import { timingSafeEqual } from 'node:crypto' import { ForbiddenError } from '@/common/errors/AppError.js' import { authSchema } from '@/contract/schemas/auth.js' diff --git a/src/tests/modules/users.test.ts b/src/tests/modules/users.test.ts index da1469d..c660074 100644 --- a/src/tests/modules/users.test.ts +++ b/src/tests/modules/users.test.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { createTestApp, extractTokenFromCookie, registerAdminAndLogin, registerSuperAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' import type { User } from '@/modules/users/schemas/index.js' +import { createTestApp, extractTokenFromCookie, registerAdminAndLogin, registerSuperAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' describe('users API', () => { let app: FastifyInstance From b48157ea5ff7ec3a7971481077f42e8c73a2befd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 11:38:01 +0000 Subject: [PATCH 04/22] Fix import order in users.test.ts: type-internal before value-external perfectionist/sort-imports requires type imports (type-external, type-internal) to precede value imports (value-external, value-internal). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S3NT5TALAVtn916JREYXwQ --- src/tests/modules/users.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/modules/users.test.ts b/src/tests/modules/users.test.ts index c660074..eba488c 100644 --- a/src/tests/modules/users.test.ts +++ b/src/tests/modules/users.test.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import type { User } from '@/modules/users/schemas/index.js' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { createTestApp, extractTokenFromCookie, registerAdminAndLogin, registerSuperAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' describe('users API', () => { From 574adbf97bcddd94f2c4d6140c02c2995eaf6db4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 11:44:10 +0000 Subject: [PATCH 05/22] Fix test passwords to satisfy new complexity requirement Update all test fixture default passwords and inline test passwords from 'password123' to 'Password123' so they pass the new complexity schema (requires uppercase, lowercase, and digit). Also update intentionally wrong passwords ('differentpw', 'wrongpassword') to schema-valid but incorrect values so service-level 409 conflict checks are still reached. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S3NT5TALAVtn916JREYXwQ --- src/tests/fixtures/index.ts | 8 ++-- src/tests/modules/audit-logs.test.ts | 6 +-- src/tests/modules/auth.test.ts | 68 ++++++++++++++-------------- src/tests/modules/profile.test.ts | 2 +- src/tests/modules/users.test.ts | 22 ++++----- 5 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/tests/fixtures/index.ts b/src/tests/fixtures/index.ts index 9d4ff07..37e6e41 100644 --- a/src/tests/fixtures/index.ts +++ b/src/tests/fixtures/index.ts @@ -20,7 +20,7 @@ export async function resetDb(app: Awaited>) { export async function registerAndLogin( app: Awaited>, - user = { email: 'test@example.com', password: 'password123' }, + user = { email: 'test@example.com', password: 'Password123' }, ) { await app.inject({ method: 'POST', @@ -51,11 +51,11 @@ async function registerWithRoleAndLogin( return extractTokenFromCookie(res.headers['set-cookie']) } -export function registerAdminAndLogin(app: Awaited>, user = { email: 'admin@example.com', password: 'password123' }) { +export function registerAdminAndLogin(app: Awaited>, user = { email: 'admin@example.com', password: 'Password123' }) { return registerWithRoleAndLogin(app, 'admin', user) } -export function registerSuperAdminAndLogin(app: Awaited>, user = { email: 'superadmin@example.com', password: 'password123' }) { +export function registerSuperAdminAndLogin(app: Awaited>, user = { email: 'superadmin@example.com', password: 'Password123' }) { return registerWithRoleAndLogin(app, 'super-admin', user) } @@ -84,7 +84,7 @@ export async function eventually(read: () => Promise, done: (value: T) => export async function registerAndLoginWithUser( app: Awaited>, - user = { email: 'test@example.com', password: 'password123' }, + user = { email: 'test@example.com', password: 'Password123' }, ) { const registerRes = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: user }) const { data } = registerRes.json<{ data: { id: string, email: string } }>() diff --git a/src/tests/modules/audit-logs.test.ts b/src/tests/modules/audit-logs.test.ts index ff5aacd..350561b 100644 --- a/src/tests/modules/audit-logs.test.ts +++ b/src/tests/modules/audit-logs.test.ts @@ -84,7 +84,7 @@ describe('audit logs API', () => { it('returns 403 when requesting another user logs', async () => { const token = await registerAndLogin(app) - const otherToken = await registerAndLogin(app, { email: 'other@example.com', password: 'password123' }) + const otherToken = await registerAndLogin(app, { email: 'other@example.com', password: 'Password123' }) // Get the other user's id by logging in and hitting /profile const profileRes = await app.inject({ @@ -213,8 +213,8 @@ describe('audit logs API', () => { }) it('records user.deleted after deleting a user', async () => { - const observerToken = await registerSuperAdminAndLogin(app, { email: 'observer@example.com', password: 'password123' }) - const adminToken = await registerAdminAndLogin(app, { email: 'todelete@example.com', password: 'password123' }) + const observerToken = await registerSuperAdminAndLogin(app, { email: 'observer@example.com', password: 'Password123' }) + const adminToken = await registerAdminAndLogin(app, { email: 'todelete@example.com', password: 'Password123' }) const profileRes = await app.inject({ method: 'GET', url: '/api/v1/profile', diff --git a/src/tests/modules/auth.test.ts b/src/tests/modules/auth.test.ts index 836352e..e06dd7c 100644 --- a/src/tests/modules/auth.test.ts +++ b/src/tests/modules/auth.test.ts @@ -26,7 +26,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'alice@example.com', password: 'password123' }, + payload: { email: 'alice@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(201) const { data } = res.json<{ data: { id: string, email: string } }>() @@ -38,13 +38,13 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'alice@example.com', password: 'password123' }, + payload: { email: 'alice@example.com', password: 'Password123' }, }) expect(res.json()).not.toHaveProperty('data.passwordHash') }) it('returns 409 for duplicate email', async () => { - const payload = { email: 'bob@example.com', password: 'password123' } + const payload = { email: 'bob@example.com', password: 'Password123' } await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload }) const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload }) expect(res.statusCode).toBe(409) @@ -63,7 +63,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'not-an-email', password: 'password123' }, + payload: { email: 'not-an-email', password: 'Password123' }, }) expect(res.statusCode).toBe(400) }) @@ -72,7 +72,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { password: 'password123' }, + payload: { password: 'Password123' }, }) expect(res.statusCode).toBe(400) }) @@ -94,7 +94,7 @@ describe('auth API', () => { await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'dave@example.com', password: 'password123' }, + payload: { email: 'dave@example.com', password: 'Password123' }, }) }) @@ -102,7 +102,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login', - payload: { email: 'dave@example.com', password: 'password123' }, + payload: { email: 'dave@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(200) const cookie = firstCookieHeader(res.headers['set-cookie']) @@ -114,7 +114,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login', - payload: { email: 'dave@example.com', password: 'password123' }, + payload: { email: 'dave@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(200) const { data } = res.json<{ data: { id: string, email: string } }>() @@ -127,7 +127,7 @@ describe('auth API', () => { const loginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', - payload: { email: 'dave@example.com', password: 'password123' }, + payload: { email: 'dave@example.com', password: 'Password123' }, }) const token = firstCookieHeader(loginRes.headers['set-cookie']).split(';')[0] const res = await app.inject({ @@ -151,7 +151,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/login', - payload: { email: 'ghost@example.com', password: 'password123' }, + payload: { email: 'ghost@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(401) }) @@ -173,7 +173,7 @@ describe('auth API', () => { await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) }) @@ -182,7 +182,7 @@ describe('auth API', () => { method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': app.config.MOBILE_API_KEY }, - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(200) const { data } = res.json<{ data: { id: string, email: string, token: string } }>() @@ -197,7 +197,7 @@ describe('auth API', () => { method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': 'wrong-key' }, - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(403) }) @@ -206,7 +206,7 @@ describe('auth API', () => { const res = await app.inject({ method: 'POST', url: '/api/v1/auth/mobile/login', - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(403) }) @@ -216,7 +216,7 @@ describe('auth API', () => { method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': app.config.MOBILE_API_KEY }, - payload: { email: 'mobile@example.com', password: 'password123' }, + payload: { email: 'mobile@example.com', password: 'Password123' }, }) const { data } = loginRes.json<{ data: { token: string } }>() @@ -243,7 +243,7 @@ describe('auth API', () => { method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': app.config.MOBILE_API_KEY }, - payload: { email: 'ghost@example.com', password: 'password123' }, + payload: { email: 'ghost@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(401) }) @@ -263,7 +263,7 @@ describe('auth API', () => { describe('/api/v1/auth/logout POST ', () => { it('clears the token cookie', async () => { - const token = await registerAndLogin(app, { email: 'logout@example.com', password: 'password123' }) + const token = await registerAndLogin(app, { email: 'logout@example.com', password: 'Password123' }) const res = await app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: `token=${token}` } }) expect(res.statusCode).toBe(200) const cookie = firstCookieHeader(res.headers['set-cookie']) @@ -272,7 +272,7 @@ describe('auth API', () => { }) it('records the logged-out user in the audit log', async () => { - const { user, token } = await registerAndLoginWithUser(app, { email: 'logout-audit@example.com', password: 'password123' }) + const { user, token } = await registerAndLoginWithUser(app, { email: 'logout-audit@example.com', password: 'Password123' }) await app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { cookie: `token=${token}` } }) @@ -306,14 +306,14 @@ describe('auth API', () => { const registerRes = await app.inject({ method: 'POST', url: '/api/v1/auth/register', - payload: { email: 'mobile-logout@example.com', password: 'password123' }, + payload: { email: 'mobile-logout@example.com', password: 'Password123' }, }) const { data: user } = registerRes.json<{ data: { id: string } }>() const loginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/mobile/login', headers: { 'x-mobile-api-key': app.config.MOBILE_API_KEY }, - payload: { email: 'mobile-logout@example.com', password: 'password123' }, + payload: { email: 'mobile-logout@example.com', password: 'Password123' }, }) const { data: { token } } = loginRes.json<{ data: { token: string } }>() @@ -345,8 +345,8 @@ describe('auth API', () => { // Register, then soft-delete the account. Returns the created id. async function registerThenDelete(email: string) { - const { data: created } = (await register(email, 'password123')).json<{ data: { id: string } }>() - const token = extractTokenFromCookie((await login(email, 'password123')).headers['set-cookie']) + const { data: created } = (await register(email, 'Password123')).json<{ data: { id: string } }>() + const token = extractTokenFromCookie((await login(email, 'Password123')).headers['set-cookie']) await app.inject({ method: 'DELETE', url: `/api/v1/users/${created.id}`, headers: { authorization: `Bearer ${token}` } }) return created.id } @@ -354,16 +354,16 @@ describe('auth API', () => { it('reactivates the same account on re-register with the correct password', async () => { const id = await registerThenDelete('erin@example.com') - const again = await register('erin@example.com', 'password123') + const again = await register('erin@example.com', 'Password123') expect(again.statusCode).toBe(201) expect(again.json<{ data: { id: string } }>().data.id).toBe(id) - expect((await login('erin@example.com', 'password123')).statusCode).toBe(200) + expect((await login('erin@example.com', 'Password123')).statusCode).toBe(200) }) it('restores the account\'s profile data on reactivation', async () => { const email = 'iris@example.com' - const { data: created } = (await register(email, 'password123')).json<{ data: { id: string } }>() - const token = extractTokenFromCookie((await login(email, 'password123')).headers['set-cookie']) + const { data: created } = (await register(email, 'Password123')).json<{ data: { id: string } }>() + const token = extractTokenFromCookie((await login(email, 'Password123')).headers['set-cookie']) const headers = { authorization: `Bearer ${token}` } // set some profile data, then self-delete @@ -371,8 +371,8 @@ describe('auth API', () => { await app.inject({ method: 'DELETE', url: `/api/v1/users/${created.id}`, headers }) // reactivate, then confirm the profile data survived - await register(email, 'password123') - const token2 = extractTokenFromCookie((await login(email, 'password123')).headers['set-cookie']) + await register(email, 'Password123') + const token2 = extractTokenFromCookie((await login(email, 'Password123')).headers['set-cookie']) const me = await app.inject({ method: 'GET', url: '/api/v1/profile', headers: { authorization: `Bearer ${token2}` } }) expect(me.statusCode).toBe(200) expect(me.json<{ data: { profile: { firstName: string | null } } }>().data.profile.firstName).toBe('Iris') @@ -381,8 +381,8 @@ describe('auth API', () => { it('reactivated account retains the user role and can access own-account routes', async () => { const id = await registerThenDelete('jay@example.com') - await register('jay@example.com', 'password123') - const token = extractTokenFromCookie((await login('jay@example.com', 'password123')).headers['set-cookie']) + await register('jay@example.com', 'Password123') + const token = extractTokenFromCookie((await login('jay@example.com', 'Password123')).headers['set-cookie']) // user:update:own permission must be restored — self-PATCH requires it const patch = await app.inject({ @@ -397,23 +397,23 @@ describe('auth API', () => { it('returns 409 when re-registering a soft-deleted account with the wrong password', async () => { await registerThenDelete('frank@example.com') - const again = await register('frank@example.com', 'differentpw') + const again = await register('frank@example.com', 'DifferentPassword1') expect(again.statusCode).toBe(409) }) it('does not let a soft-deleted account log in', async () => { await registerThenDelete('grace@example.com') - expect((await login('grace@example.com', 'password123')).statusCode).toBe(401) + expect((await login('grace@example.com', 'Password123')).statusCode).toBe(401) }) it('does not block correct-password reactivation after a failed wrong-password attempt', async () => { const id = await registerThenDelete('henry@example.com') // wrong password → 409, no orphan account created - expect((await register('henry@example.com', 'wrongpassword')).statusCode).toBe(409) + expect((await register('henry@example.com', 'WrongPassword1')).statusCode).toBe(409) // original password still reactivates the same account - const again = await register('henry@example.com', 'password123') + const again = await register('henry@example.com', 'Password123') expect(again.statusCode).toBe(201) expect(again.json<{ data: { id: string } }>().data.id).toBe(id) }) diff --git a/src/tests/modules/profile.test.ts b/src/tests/modules/profile.test.ts index 0e81bba..117afb2 100644 --- a/src/tests/modules/profile.test.ts +++ b/src/tests/modules/profile.test.ts @@ -47,7 +47,7 @@ describe('profile API', () => { }) it('returns the user that matches the JWT, not another user', async () => { - const otherToken = await registerAndLogin(app, { email: 'other@example.com', password: 'password123' }) + const otherToken = await registerAndLogin(app, { email: 'other@example.com', password: 'Password123' }) const res = await app.inject({ method: 'GET', url: '/api/v1/profile', diff --git a/src/tests/modules/users.test.ts b/src/tests/modules/users.test.ts index eba488c..12e9d2c 100644 --- a/src/tests/modules/users.test.ts +++ b/src/tests/modules/users.test.ts @@ -24,7 +24,7 @@ describe('users API', () => { // ── helpers ──────────────────────────────────────────────────────────────── - async function createUser(email = 'user@example.com', password = 'password123') { + async function createUser(email = 'user@example.com', password = 'Password123') { const res = await app.inject({ method: 'POST', url: '/api/v1/users', @@ -152,7 +152,7 @@ describe('users API', () => { }) it('returns assigned roles for a user registered via auth', async () => { - const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { email: 'withrole@example.com', password: 'password123' } }) + const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { email: 'withrole@example.com', password: 'Password123' } }) const userId = reg.json<{ data: { id: string } }>().data.id const res = await app.inject({ method: 'GET', @@ -206,7 +206,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'new@example.com', password: 'password123' }, + payload: { email: 'new@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(201) const { data } = res.json<{ data: User }>() @@ -228,7 +228,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'new@example.com', password: 'password123' }, + payload: { email: 'new@example.com', password: 'Password123' }, }) expect(res.json()).not.toHaveProperty('data.passwordHash') }) @@ -239,7 +239,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'dup@example.com', password: 'password123' }, + payload: { email: 'dup@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(409) }) @@ -255,7 +255,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'reuse@example.com', password: 'password123' }, + payload: { email: 'reuse@example.com', password: 'Password123' }, }) expect(res.statusCode).toBe(201) }) @@ -265,7 +265,7 @@ describe('users API', () => { method: 'POST', url: '/api/v1/users', headers: { authorization: `Bearer ${token}` }, - payload: { email: 'bad-email', password: 'password123' }, + payload: { email: 'bad-email', password: 'Password123' }, }) expect(res.statusCode).toBe(400) }) @@ -410,9 +410,9 @@ describe('users API', () => { describe('authorization', () => { // Register a normal (non-admin) user and return { id, token }. async function registerNormal(email: string) { - const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { email, password: 'password123' } }) + const reg = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { email, password: 'Password123' } }) const id = reg.json<{ data: { id: string } }>().data.id - const loginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email, password: 'password123' } }) + const loginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email, password: 'Password123' } }) const userToken = extractTokenFromCookie(loginRes.headers['set-cookie']) return { id, token: userToken } } @@ -434,7 +434,7 @@ describe('users API', () => { it('forbids a non-admin from creating users', async () => { const { token: t } = await registerNormal('n3@example.com') - const res = await app.inject({ method: 'POST', url: '/api/v1/users', headers: auth(t), payload: { email: 'x@example.com', password: 'password123' } }) + const res = await app.inject({ method: 'POST', url: '/api/v1/users', headers: auth(t), payload: { email: 'x@example.com', password: 'Password123' } }) expect(res.statusCode).toBe(403) }) @@ -550,7 +550,7 @@ describe('users API', () => { // attempt mass-assignment of role through the self-update path await app.inject({ method: 'PATCH', url: `/api/v1/users/${id}`, headers: auth(t), payload: { email: 'escalate@example.com', role: 'admin' } }) // re-login so the token reflects any (un)changed role, then probe an admin-only route - const reloginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'escalate@example.com', password: 'password123' } }) + const reloginRes = await app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email: 'escalate@example.com', password: 'Password123' } }) const fresh = extractTokenFromCookie(reloginRes.headers['set-cookie']) const res = await app.inject({ method: 'GET', url: '/api/v1/users', headers: auth(fresh) }) expect(res.statusCode).toBe(403) From 0a0d1fbccc9c9349466b021c78b2d2f3bc4eb447 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 11:53:33 +0000 Subject: [PATCH 06/22] Fix test failures from security changes - Revert admin to non-system-role in seed.ts (only super-admin is a system role; roles.test.ts explicitly asserts admin.isSystemRole=false) - Update migration 0009 to no-op accordingly - Update permissions count expectation from 15 to 19 (4 product permissions were added to the seed) - Switch products.test.ts to use admin token since product mutations now require product:create/update/delete:any permissions - Remove 'email' from auth.logged_in audit metadata expectation (PII intentionally stripped from audit logs) - Fix audit-log product mutation smoke tests to use admin token Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S3NT5TALAVtn916JREYXwQ --- migrations/0009_backfill_admin_system_role.sql | 3 ++- src/db/seed.ts | 2 +- src/tests/modules/audit-logs.test.ts | 10 ++++------ src/tests/modules/permissions.test.ts | 2 +- src/tests/modules/products.test.ts | 4 ++-- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/migrations/0009_backfill_admin_system_role.sql b/migrations/0009_backfill_admin_system_role.sql index 3ca08f6..b850812 100644 --- a/migrations/0009_backfill_admin_system_role.sql +++ b/migrations/0009_backfill_admin_system_role.sql @@ -1 +1,2 @@ -UPDATE "roles" SET "is_system_role" = true WHERE "name" = 'admin'; +-- no-op: admin role is not a system role; only super-admin is +SELECT 1; diff --git a/src/db/seed.ts b/src/db/seed.ts index 2ae4874..dab5e00 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -6,7 +6,7 @@ import { permissions, rolePermissions, roles } from '@/db/schema/index.js' const SEED_ROLES = [ { name: 'super-admin', description: 'Full system access', isSystemRole: true }, - { name: 'admin', description: 'Administrative access', isSystemRole: true }, + { name: 'admin', description: 'Administrative access', isSystemRole: false }, { name: 'user', description: 'Standard user access', isSystemRole: false }, ] diff --git a/src/tests/modules/audit-logs.test.ts b/src/tests/modules/audit-logs.test.ts index 350561b..1956e7f 100644 --- a/src/tests/modules/audit-logs.test.ts +++ b/src/tests/modules/audit-logs.test.ts @@ -143,13 +143,12 @@ describe('audit logs API', () => { describe('mutation logging smoke tests', () => { it('records product.created after creating a product', async () => { - const userToken = await registerAndLogin(app) const adminToken = await registerAdminAndLogin(app) await app.inject({ method: 'POST', url: '/api/v1/products', - headers: { authorization: `Bearer ${userToken}` }, + headers: { authorization: `Bearer ${adminToken}` }, payload: { name: 'Widget', price: 9.99, stock: 5 }, }) @@ -167,13 +166,12 @@ describe('audit logs API', () => { }) it('records product.deleted with metadata after deleting a product', async () => { - const userToken = await registerAndLogin(app) const adminToken = await registerAdminAndLogin(app) const created = await app.inject({ method: 'POST', url: '/api/v1/products', - headers: { authorization: `Bearer ${userToken}` }, + headers: { authorization: `Bearer ${adminToken}` }, payload: { name: 'Doomed Widget', price: 4.99, stock: 1 }, }) const productId = created.json().data.id @@ -181,7 +179,7 @@ describe('audit logs API', () => { await app.inject({ method: 'DELETE', url: `/api/v1/products/${productId}`, - headers: { authorization: `Bearer ${userToken}` }, + headers: { authorization: `Bearer ${adminToken}` }, }) const body = await eventually( @@ -209,7 +207,7 @@ describe('audit logs API', () => { ) const log = body.data.find((l: { action: string }) => l.action === 'auth.logged_in') expect(log).toBeDefined() - expect(log.metadata).toMatchObject({ email: expect.any(String), ip: expect.any(String) }) + expect(log.metadata).toMatchObject({ ip: expect.any(String) }) }) it('records user.deleted after deleting a user', async () => { diff --git a/src/tests/modules/permissions.test.ts b/src/tests/modules/permissions.test.ts index 2f4cc45..75adff5 100644 --- a/src/tests/modules/permissions.test.ts +++ b/src/tests/modules/permissions.test.ts @@ -60,7 +60,7 @@ describe('permissions API', () => { it('returns all 15 seeded permissions', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/permissions', headers: auth(adminToken) }) const { data } = res.json<{ data: Permission[] }>() - expect(data).toHaveLength(15) + expect(data).toHaveLength(19) }) it('returns permissions with correct shape', async () => { diff --git a/src/tests/modules/products.test.ts b/src/tests/modules/products.test.ts index 60b3700..0c3aa4d 100644 --- a/src/tests/modules/products.test.ts +++ b/src/tests/modules/products.test.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { createTestApp, registerAndLogin, resetDb } from '@/tests/fixtures/index.js' +import { createTestApp, registerAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' describe('products API', () => { let app: FastifyInstance @@ -12,7 +12,7 @@ describe('products API', () => { beforeEach(async () => { await resetDb(app) - token = await registerAndLogin(app) + token = await registerAdminAndLogin(app) }) afterAll(async () => { From f64a8829d3bd1789a15bda0ac88b4ce482c2cfb5 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:03:08 +0000 Subject: [PATCH 07/22] Fix 4 remaining security issues from PR review - loginUser: always run bcrypt.compare using DUMMY_HASH when user not found, preventing timing-based email enumeration on the login endpoint - /metrics: add metrics:read:any permission gate (super-admin only) to prevent any authenticated user from reading Prometheus data - rate-limit: remove allowList ['127.0.0.1'] which was bypassable by forging X-Forwarded-For: 127.0.0.1 against the XFF key generator - cors: throw at startup if CORS_ORIGIN is unset in production, matching the fast-fail pattern already used for COOKIE_SECRET Co-authored-by: Ruje Alfon Co-Authored-By: Claude Sonnet 4.6 --- src/common/constants/index.ts | 3 +++ src/db/seed.ts | 1 + src/modules/auth/services/auth.service.ts | 11 +++++++---- src/plugins/cors.ts | 4 ++++ src/plugins/metrics.ts | 3 ++- src/plugins/rate-limit.ts | 1 - 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/common/constants/index.ts b/src/common/constants/index.ts index 488bc5b..227e1af 100644 --- a/src/common/constants/index.ts +++ b/src/common/constants/index.ts @@ -36,4 +36,7 @@ export const PERMISSIONS = { AUDIT_LOG: { READ_ANY: 'audit-log:read:any', }, + METRICS: { + READ_ANY: 'metrics:read:any', + }, } as const diff --git a/src/db/seed.ts b/src/db/seed.ts index dab5e00..c7f608f 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -30,6 +30,7 @@ const SEED_PERMISSIONS = [ { resource: 'product', action: 'update', scope: 'any' }, { resource: 'product', action: 'delete', scope: 'any' }, { resource: 'audit-log', action: 'read', scope: 'any' }, + { resource: 'metrics', action: 'read', scope: 'any' }, ] const ROLE_PERMISSIONS: Record = { diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index ec35036..460060d 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -72,11 +72,14 @@ export async function registerUser(db: Db, body: RegisterBody) { export async function loginUser(db: Db, body: LoginBody) { const user = await db.query.users.findFirst({ where: and(eq(users.email, body.email), isNull(users.deletedAt)) }) - if (!user) - throw new UnauthorizedError('Invalid email or password') - const valid = await bcrypt.compare(body.password, user.passwordHash) - if (!valid) + // Always run bcrypt to prevent timing-based email enumeration. When no user + // is found the DUMMY_HASH comparison ensures a constant-time response. + const valid = user + ? await bcrypt.compare(body.password, user.passwordHash) + : (await bcrypt.compare(body.password, DUMMY_HASH), false) + + if (!user || !valid) throw new UnauthorizedError('Invalid email or password') return { id: user.id, email: user.email } diff --git a/src/plugins/cors.ts b/src/plugins/cors.ts index 524428e..840ecc7 100644 --- a/src/plugins/cors.ts +++ b/src/plugins/cors.ts @@ -7,6 +7,10 @@ const corsPlugin: FastifyPluginAsync = async (fastify) => { ? fastify.config.CORS_ORIGIN.split(',').map(s => s.trim()).filter(Boolean) : [] + if (fastify.config.NODE_ENV === 'production' && configuredOrigins.length === 0) { + throw new Error('CORS_ORIGIN must be set in production') + } + const origin = fastify.config.NODE_ENV === 'production' ? configuredOrigins : ['http://localhost:3000', 'http://localhost:5173'] diff --git a/src/plugins/metrics.ts b/src/plugins/metrics.ts index 5e2236c..e084bc6 100644 --- a/src/plugins/metrics.ts +++ b/src/plugins/metrics.ts @@ -1,6 +1,7 @@ import type { FastifyPluginAsync } from 'fastify' import fp from 'fastify-plugin' import { collectDefaultMetrics, Registry } from 'prom-client' +import { PERMISSIONS } from '@/common/constants/index.js' declare module 'fastify' { interface FastifyInstance { @@ -17,7 +18,7 @@ const metricsPlugin: FastifyPluginAsync = async (fastify) => { fastify.get('/metrics', { schema: { hide: true }, - preValidation: [fastify.authenticate], + preValidation: [fastify.authenticate, fastify.requirePermission(PERMISSIONS.METRICS.READ_ANY)], handler: async (_request, reply) => { reply.header('Content-Type', registry.contentType) return registry.metrics() diff --git a/src/plugins/rate-limit.ts b/src/plugins/rate-limit.ts index 79b92f6..42edc7a 100644 --- a/src/plugins/rate-limit.ts +++ b/src/plugins/rate-limit.ts @@ -11,7 +11,6 @@ const rateLimitPlugin: FastifyPluginAsync = async (fastify) => { await fastify.register(rateLimit, { max: 100, timeWindow: '15 minutes', - allowList: ['127.0.0.1'], redis: fastify.redis, // Parse the leftmost entry from x-forwarded-for so clients behind a trusted // reverse proxy are keyed by their real IP. Do not trust the full header From b00f4abf10d9fe72cd7450e984869060ac52bb08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 12:06:05 +0000 Subject: [PATCH 08/22] Fix three remaining timing and permissions issues - registerUser: run dummy bcrypt.compare before throwing ConflictError on active-email conflict, so the fast-409 path can't be timed to enumerate actively registered emails (was 0 bcrypt ops vs new-email's 2 ops) - mobileLogin: replace length-guard + timingSafeEqual on raw buffers with SHA-256 hash comparison; both values are always hashed to 32 bytes so timingSafeEqual is always constant-time and the check no longer leaks MOBILE_API_KEY byte-length through the early length short-circuit - permissions test: update expected count from 19 to 20 to account for the metrics:read:any permission added in the previous commit Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01S3NT5TALAVtn916JREYXwQ --- src/modules/auth/routes/index.ts | 11 ++++++----- src/modules/auth/services/auth.service.ts | 6 +++++- src/tests/modules/permissions.test.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/modules/auth/routes/index.ts b/src/modules/auth/routes/index.ts index b1a6144..65fb3ce 100644 --- a/src/modules/auth/routes/index.ts +++ b/src/modules/auth/routes/index.ts @@ -1,6 +1,5 @@ import type { FastifyReply } from 'fastify' -import { Buffer } from 'node:buffer' -import { timingSafeEqual } from 'node:crypto' +import { createHash, timingSafeEqual } from 'node:crypto' import { ForbiddenError } from '@/common/errors/AppError.js' import { authSchema } from '@/contract/schemas/auth.js' import { logAudit } from '@/modules/audit-logs/helpers/log-audit.js' @@ -31,9 +30,11 @@ export default createFastifyRpcPlugin(authSchema, { }, mobileLogin: async ({ body, request, reply }) => { - const provided = Buffer.from(request.headers['x-mobile-api-key'] as string ?? '') - const expected = Buffer.from(request.server.config.MOBILE_API_KEY) - if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) + // Hash both values to a fixed 32-byte digest before comparing so the + // comparison is always constant-time and leaks neither key length nor content. + const provided = createHash('sha256').update(request.headers['x-mobile-api-key'] as string ?? '').digest() + const expected = createHash('sha256').update(request.server.config.MOBILE_API_KEY).digest() + if (!timingSafeEqual(provided, expected)) throw new ForbiddenError('Mobile login is restricted to mobile clients') const user = await authService.loginUser(request.server.db, body) const token = await signToken(user, reply) diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 460060d..7566c7e 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -17,8 +17,12 @@ export async function registerUser(db: Db, body: RegisterBody) { // (the partial unique index only covers deletedAt IS NULL), so the conflict // check must be scoped to active rows specifically. const active = await db.query.users.findFirst({ where: and(eq(users.email, body.email), isNull(users.deletedAt)) }) - if (active) + if (active) { + // Normalize timing — run a dummy compare so active-conflict responses take + // the same bcrypt time as the dead-account path, preventing enumeration. + await bcrypt.compare(body.password, DUMMY_HASH) throw new ConflictError('An account with this email already exists') + } // Reactivate a soft-deleted account if the password matches (within the 90-day // window before the cleanup cron hard-deletes it). Profile row still exists, so diff --git a/src/tests/modules/permissions.test.ts b/src/tests/modules/permissions.test.ts index 75adff5..262a323 100644 --- a/src/tests/modules/permissions.test.ts +++ b/src/tests/modules/permissions.test.ts @@ -60,7 +60,7 @@ describe('permissions API', () => { it('returns all 15 seeded permissions', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/permissions', headers: auth(adminToken) }) const { data } = res.json<{ data: Permission[] }>() - expect(data).toHaveLength(19) + expect(data).toHaveLength(20) }) it('returns permissions with correct shape', async () => { From 2e83eb5b21a525359f2f33e141810e45663c17b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 16:44:04 +0000 Subject: [PATCH 09/22] Remove redundant bcrypt.compare in new-user registration path The dummy bcrypt.compare before bcrypt.hash doubled registration latency (~200ms vs ~100ms for conflict paths), creating a timing oracle that distinguished new-email registrations from conflicts. bcrypt.hash at cost 12 already takes the same wall time as a single bcrypt.compare, so the dummy call is unnecessary overhead. --- src/modules/auth/services/auth.service.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 7566c7e..c358435 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -40,9 +40,6 @@ export async function registerUser(db: Db, body: RegisterBody) { throw new ConflictError('An account with this email already exists') } - // Normalize timing vs the dead-account path above. - await bcrypt.compare(body.password, DUMMY_HASH) - const passwordHash = await bcrypt.hash(body.password, 12) try { From e83dbdc31662a5ff12955146a6f85f470e886a36 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 16:44:51 +0000 Subject: [PATCH 10/22] Rename migration 0009 to reflect its no-op intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration was named backfill_admin_system_role but contains only SELECT 1 — admin was intentionally left as a non-system role (only super-admin carries isSystemRole: true). The old name implied the opposite of the actual design decision and would have caused confusion in migration history. Updated the journal tag to match. --- ...dmin_system_role.sql => 0009_noop_admin_role_not_system.sql} | 0 migrations/meta/_journal.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename migrations/{0009_backfill_admin_system_role.sql => 0009_noop_admin_role_not_system.sql} (100%) diff --git a/migrations/0009_backfill_admin_system_role.sql b/migrations/0009_noop_admin_role_not_system.sql similarity index 100% rename from migrations/0009_backfill_admin_system_role.sql rename to migrations/0009_noop_admin_role_not_system.sql diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 612eb30..5e262d2 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -69,7 +69,7 @@ "idx": 9, "version": "7", "when": 1751034000000, - "tag": "0009_backfill_admin_system_role", + "tag": "0009_noop_admin_role_not_system", "breakpoints": true } ] From fc05f20e67d3df644ab32d714f5998983f9ed30a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 16:51:31 +0000 Subject: [PATCH 11/22] Fix comma expression in loginUser and sync RouteSchema with RouteMap Replace the obscure JS comma expression used for bcrypt timing with .then(() => false) which makes the side-effect-then-discard intent explicit. Add missing tags and rateLimit fields to RouteSchema so it stays in sync with RouteMap; the two types described the same shape but RouteSchema was silently missing both fields. --- src/contract/types.ts | 2 ++ src/modules/auth/services/auth.service.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/contract/types.ts b/src/contract/types.ts index 2d5666c..a66eb83 100644 --- a/src/contract/types.ts +++ b/src/contract/types.ts @@ -13,6 +13,8 @@ export interface RouteSchema< auth?: boolean optionalAuth?: boolean permission?: string + tags?: string[] + rateLimit?: { max: number, timeWindow: string } query?: TQuery params?: TParams body?: TBody diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index c358435..90e8b0a 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -78,7 +78,7 @@ export async function loginUser(db: Db, body: LoginBody) { // is found the DUMMY_HASH comparison ensures a constant-time response. const valid = user ? await bcrypt.compare(body.password, user.passwordHash) - : (await bcrypt.compare(body.password, DUMMY_HASH), false) + : await bcrypt.compare(body.password, DUMMY_HASH).then(() => false) if (!user || !valid) throw new UnauthorizedError('Invalid email or password') From 617fffb69e0ab9ec7d6312976541a15d9961ddda Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 17:09:58 +0000 Subject: [PATCH 12/22] Fix MOBILE_API_KEY empty-string bypass and add security test coverage Guard mobileLogin against falsy MOBILE_API_KEY so SHA256('')===SHA256('') cannot bypass the check when the env var is unset. Add password complexity test (length-passing but no uppercase), metrics endpoint auth/RBAC tests, and products write-endpoint 403 tests for the regular user role. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01BwkeoFPQtp1C3yaUhkcNTH --- src/modules/auth/routes/index.ts | 2 ++ src/tests/modules/auth.test.ts | 9 +++++ src/tests/modules/metrics.test.ts | 54 +++++++++++++++++++++++++++++ src/tests/modules/products.test.ts | 55 +++++++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/tests/modules/metrics.test.ts diff --git a/src/modules/auth/routes/index.ts b/src/modules/auth/routes/index.ts index 65fb3ce..2575f60 100644 --- a/src/modules/auth/routes/index.ts +++ b/src/modules/auth/routes/index.ts @@ -30,6 +30,8 @@ export default createFastifyRpcPlugin(authSchema, { }, mobileLogin: async ({ body, request, reply }) => { + if (!request.server.config.MOBILE_API_KEY) + throw new ForbiddenError('Mobile login is restricted to mobile clients') // Hash both values to a fixed 32-byte digest before comparing so the // comparison is always constant-time and leaks neither key length nor content. const provided = createHash('sha256').update(request.headers['x-mobile-api-key'] as string ?? '').digest() diff --git a/src/tests/modules/auth.test.ts b/src/tests/modules/auth.test.ts index e06dd7c..ed7d77a 100644 --- a/src/tests/modules/auth.test.ts +++ b/src/tests/modules/auth.test.ts @@ -59,6 +59,15 @@ describe('auth API', () => { expect(res.statusCode).toBe(400) }) + it('returns 400 for password meeting length but not complexity', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { email: 'alice@example.com', password: 'password123' }, + }) + expect(res.statusCode).toBe(400) + }) + it('returns 400 for invalid email format', async () => { const res = await app.inject({ method: 'POST', diff --git a/src/tests/modules/metrics.test.ts b/src/tests/modules/metrics.test.ts new file mode 100644 index 0000000..be24008 --- /dev/null +++ b/src/tests/modules/metrics.test.ts @@ -0,0 +1,54 @@ +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { createTestApp, registerAdminAndLogin, registerAndLogin, registerSuperAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' + +describe('metrics API', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await resetDb(app) + }) + + afterAll(async () => { + await app.close() + }) + + it('returns 401 without a token', async () => { + const res = await app.inject({ method: 'GET', url: '/metrics' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 403 with a regular user JWT (no metrics:read:any permission)', async () => { + const token = await registerAndLogin(app, { email: 'user@example.com', password: 'Password123' }) + const res = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 403 with an admin JWT (admin role does not have metrics:read:any)', async () => { + const token = await registerAdminAndLogin(app) + const res = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 200 with a super-admin JWT', async () => { + const token = await registerSuperAdminAndLogin(app) + const res = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + }) +}) diff --git a/src/tests/modules/products.test.ts b/src/tests/modules/products.test.ts index 0c3aa4d..515f2bf 100644 --- a/src/tests/modules/products.test.ts +++ b/src/tests/modules/products.test.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { createTestApp, registerAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' +import { createTestApp, registerAdminAndLogin, registerAndLogin, resetDb } from '@/tests/fixtures/index.js' describe('products API', () => { let app: FastifyInstance @@ -86,4 +86,57 @@ describe('products API', () => { }) expect(del.statusCode).toBe(204) }) + + describe('RBAC: regular user role', () => { + let userToken: string + + beforeEach(async () => { + userToken = await registerAndLogin(app, { email: 'regular@example.com', password: 'Password123' }) + }) + + it('returns 403 for POST /api/v1/products', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/products', + headers: { authorization: `Bearer ${userToken}` }, + payload: { name: 'Widget', price: 9.99, stock: 100 }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 403 for PATCH /api/v1/products/:id', async () => { + const create = await app.inject({ + method: 'POST', + url: '/api/v1/products', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Gadget', price: 19.99, stock: 50 }, + }) + const { data: created } = create.json<{ data: { id: string } }>() + + const res = await app.inject({ + method: 'PATCH', + url: `/api/v1/products/${created.id}`, + headers: { authorization: `Bearer ${userToken}` }, + payload: { price: 24.99 }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 403 for DELETE /api/v1/products/:id', async () => { + const create = await app.inject({ + method: 'POST', + url: '/api/v1/products', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Doohickey', price: 4.99, stock: 10 }, + }) + const { data: created } = create.json<{ data: { id: string } }>() + + const res = await app.inject({ + method: 'DELETE', + url: `/api/v1/products/${created.id}`, + headers: { authorization: `Bearer ${userToken}` }, + }) + expect(res.statusCode).toBe(403) + }) + }) }) From 87e988ca9ce225cd24cba2b9d29252dab0113eae Mon Sep 17 00:00:00 2001 From: Ruje Alfon Date: Sun, 28 Jun 2026 01:15:11 +0800 Subject: [PATCH 13/22] Fix formatting of RBAC description in products test --- src/tests/modules/products.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/modules/products.test.ts b/src/tests/modules/products.test.ts index 515f2bf..55e5d17 100644 --- a/src/tests/modules/products.test.ts +++ b/src/tests/modules/products.test.ts @@ -87,7 +87,7 @@ describe('products API', () => { expect(del.statusCode).toBe(204) }) - describe('RBAC: regular user role', () => { + describe('regular user role :RBAC', () => { let userToken: string beforeEach(async () => { From 53971ab177a3e1dd388ce4dfe5569ad2de4f6507 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 17:25:30 +0000 Subject: [PATCH 14/22] Add MOBILE_API_KEY production fast-fail and gate /health/details by permission Add mobile-auth plugin that throws on startup when MOBILE_API_KEY is empty in production, mirroring the existing COOKIE_SECRET and CORS_ORIGIN guards. Add health:read:details permission assigned to admin and super-admin roles, and enforce it on GET /health/details (previously any authenticated user could read heap/event-loop internals). Add health test coverage for 401/403/200. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01BwkeoFPQtp1C3yaUhkcNTH --- src/app.ts | 2 + src/common/constants/index.ts | 3 ++ src/db/seed.ts | 2 + src/modules/health/routes/index.ts | 4 +- src/plugins/mobile-auth.ts | 9 +++++ src/tests/modules/health.test.ts | 63 ++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/plugins/mobile-auth.ts create mode 100644 src/tests/modules/health.test.ts diff --git a/src/app.ts b/src/app.ts index 1bc05dd..838a9c4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,6 +22,7 @@ import dbPlugin from './plugins/db.js' import helmetPlugin from './plugins/helmet.js' import jwtPlugin from './plugins/jwt.js' import metricsPlugin from './plugins/metrics.js' +import mobileAuthPlugin from './plugins/mobile-auth.js' import multipartPlugin from './plugins/multipart.js' import rateLimitPlugin from './plugins/rate-limit.js' import redisPlugin from './plugins/redis.js' @@ -77,6 +78,7 @@ export async function buildApp() { // Auth await fastify.register(jwtPlugin) + await fastify.register(mobileAuthPlugin) await fastify.register(authDecorator) await fastify.register(requestIdHook) diff --git a/src/common/constants/index.ts b/src/common/constants/index.ts index 227e1af..d655f79 100644 --- a/src/common/constants/index.ts +++ b/src/common/constants/index.ts @@ -39,4 +39,7 @@ export const PERMISSIONS = { METRICS: { READ_ANY: 'metrics:read:any', }, + HEALTH: { + READ_DETAILS: 'health:read:details', + }, } as const diff --git a/src/db/seed.ts b/src/db/seed.ts index c7f608f..106076e 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -31,11 +31,13 @@ const SEED_PERMISSIONS = [ { resource: 'product', action: 'delete', scope: 'any' }, { resource: 'audit-log', action: 'read', scope: 'any' }, { resource: 'metrics', action: 'read', scope: 'any' }, + { resource: 'health', action: 'read', scope: 'details' }, ] const ROLE_PERMISSIONS: Record = { 'super-admin': SEED_PERMISSIONS.map(p => `${p.resource}:${p.action}:${p.scope}`), 'admin': [ + 'health:read:details', 'user:create:any', 'user:read:any', 'user:update:any', diff --git a/src/modules/health/routes/index.ts b/src/modules/health/routes/index.ts index f91953b..06f49da 100644 --- a/src/modules/health/routes/index.ts +++ b/src/modules/health/routes/index.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' +import { PERMISSIONS } from '@/common/constants/index.js' import { apiErrorSchema, apiSuccessSchema } from '@/common/schemas/index.js' import * as controller from '@/modules/health/controllers/health.controller.js' @@ -44,9 +45,10 @@ const healthRoutes: FastifyPluginAsyncZod = async (fastify) => { underPressure: z.boolean(), })), 401: apiErrorSchema, + 403: apiErrorSchema, }, }, - preValidation: [fastify.authenticate], + preValidation: [fastify.authenticate, fastify.requirePermission(PERMISSIONS.HEALTH.READ_DETAILS)], handler: controller.details, }) } diff --git a/src/plugins/mobile-auth.ts b/src/plugins/mobile-auth.ts new file mode 100644 index 0000000..8a3a45e --- /dev/null +++ b/src/plugins/mobile-auth.ts @@ -0,0 +1,9 @@ +import type { FastifyPluginAsync } from 'fastify' +import fp from 'fastify-plugin' + +const mobileAuthPlugin: FastifyPluginAsync = async (fastify) => { + if (fastify.config.NODE_ENV === 'production' && !fastify.config.MOBILE_API_KEY) + throw new Error('MOBILE_API_KEY must be set in production') +} + +export default fp(mobileAuthPlugin, { name: 'mobile-auth' }) diff --git a/src/tests/modules/health.test.ts b/src/tests/modules/health.test.ts new file mode 100644 index 0000000..aae1950 --- /dev/null +++ b/src/tests/modules/health.test.ts @@ -0,0 +1,63 @@ +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { createTestApp, registerAdminAndLogin, registerAndLogin, registerSuperAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' + +describe('health API', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await resetDb(app) + }) + + afterAll(async () => { + await app.close() + }) + + describe('GET /health/live', () => { + it('returns 200 without authentication', async () => { + const res = await app.inject({ method: 'GET', url: '/health/live' }) + expect(res.statusCode).toBe(200) + }) + }) + + describe('GET /health/details', () => { + it('returns 401 without a token', async () => { + const res = await app.inject({ method: 'GET', url: '/health/details' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 403 for regular user role', async () => { + const token = await registerAndLogin(app, { email: 'user@example.com', password: 'Password123' }) + const res = await app.inject({ + method: 'GET', + url: '/health/details', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 200 for admin role', async () => { + const token = await registerAdminAndLogin(app) + const res = await app.inject({ + method: 'GET', + url: '/health/details', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + }) + + it('returns 200 for super-admin role', async () => { + const token = await registerSuperAdminAndLogin(app) + const res = await app.inject({ + method: 'GET', + url: '/health/details', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + }) + }) +}) From 8987226406c04bd2c0385f7b1e16e6a76a386bbd Mon Sep 17 00:00:00 2001 From: Ruje Alfon Date: Sun, 28 Jun 2026 01:30:40 +0800 Subject: [PATCH 15/22] Fix casing in HTTP method descriptions for health API tests --- src/tests/modules/health.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/modules/health.test.ts b/src/tests/modules/health.test.ts index aae1950..54cfd30 100644 --- a/src/tests/modules/health.test.ts +++ b/src/tests/modules/health.test.ts @@ -17,14 +17,14 @@ describe('health API', () => { await app.close() }) - describe('GET /health/live', () => { + describe('get /health/live', () => { it('returns 200 without authentication', async () => { const res = await app.inject({ method: 'GET', url: '/health/live' }) expect(res.statusCode).toBe(200) }) }) - describe('GET /health/details', () => { + describe('get /health/details', () => { it('returns 401 without a token', async () => { const res = await app.inject({ method: 'GET', url: '/health/details' }) expect(res.statusCode).toBe(401) From 48ccd0d1147ca50b2e3c96c76d0219a529a8cda9 Mon Sep 17 00:00:00 2001 From: Ruje Alfon Date: Sun, 28 Jun 2026 08:25:27 +0800 Subject: [PATCH 16/22] Refactor health API to include memory usage metrics and update tests for detailed responses --- src/db/seed.ts | 2 +- .../health/controllers/health.controller.ts | 29 +++++++++++++++-- src/tests/modules/health.test.ts | 32 +++++++++++++++++-- src/tests/modules/permissions.test.ts | 23 +++---------- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/src/db/seed.ts b/src/db/seed.ts index 106076e..706c611 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -10,7 +10,7 @@ const SEED_ROLES = [ { name: 'user', description: 'Standard user access', isSystemRole: false }, ] -const SEED_PERMISSIONS = [ +export const SEED_PERMISSIONS = [ { resource: 'user', action: 'create', scope: 'any' }, { resource: 'user', action: 'read', scope: 'any' }, { resource: 'user', action: 'update', scope: 'any' }, diff --git a/src/modules/health/controllers/health.controller.ts b/src/modules/health/controllers/health.controller.ts index d7adf44..a7f8f6d 100644 --- a/src/modules/health/controllers/health.controller.ts +++ b/src/modules/health/controllers/health.controller.ts @@ -1,6 +1,20 @@ import type { FastifyReply, FastifyRequest } from 'fastify' +import { performance } from 'node:perf_hooks' +import process from 'node:process' import { checkDb, checkRedis } from '@/modules/health/services/health.service.js' +interface PressureMetrics { + heapUsed: number + rssBytes: number + eventLoopDelay: number + eventLoopUtilized: number +} + +interface PressureDecorators { + memoryUsage?: () => PressureMetrics + isUnderPressure?: () => boolean +} + export async function liveness(_request: FastifyRequest, _reply: FastifyReply) { return { success: true as const, data: { status: 'ok' } } } @@ -25,18 +39,27 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) { } export async function details(request: FastifyRequest, _reply: FastifyReply) { - const memory = request.server.memoryUsage() + const pressure = request.server as FastifyRequest['server'] & PressureDecorators + const processMemory = process.memoryUsage() + const memory = pressure.memoryUsage?.() ?? { + heapUsed: processMemory.heapUsed, + rssBytes: processMemory.rss, + eventLoopDelay: 0, + eventLoopUtilized: performance.eventLoopUtilization().utilization, + } + const underPressure = pressure.isUnderPressure?.() ?? false + return { success: true as const, data: { - status: request.server.isUnderPressure() ? 'degraded' : 'ok', + status: underPressure ? 'degraded' : 'ok', memory: { heapUsed: memory.heapUsed, rssBytes: memory.rssBytes, eventLoopDelay: memory.eventLoopDelay, eventLoopUtilized: memory.eventLoopUtilized, }, - underPressure: request.server.isUnderPressure(), + underPressure, }, } } diff --git a/src/tests/modules/health.test.ts b/src/tests/modules/health.test.ts index 54cfd30..242466a 100644 --- a/src/tests/modules/health.test.ts +++ b/src/tests/modules/health.test.ts @@ -21,10 +21,36 @@ describe('health API', () => { it('returns 200 without authentication', async () => { const res = await app.inject({ method: 'GET', url: '/health/live' }) expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ success: true, data: { status: 'ok' } }) + }) + }) + + describe('get /health/ready', () => { + it('returns 200 without authentication when dependencies are reachable', async () => { + const res = await app.inject({ method: 'GET', url: '/health/ready' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ success: true, data: { status: 'ready' } }) }) }) describe('get /health/details', () => { + const expectHealthDetails = (body: { + success: boolean + data: { + status: string + memory: Record + underPressure: boolean + } + }) => { + expect(body.success).toBe(true) + expect(['ok', 'degraded']).toContain(body.data.status) + expect(body.data.underPressure).toEqual(expect.any(Boolean)) + expect(body.data.memory.heapUsed).toEqual(expect.any(Number)) + expect(body.data.memory.rssBytes).toEqual(expect.any(Number)) + expect(body.data.memory.eventLoopDelay).toEqual(expect.any(Number)) + expect(body.data.memory.eventLoopUtilized).toEqual(expect.any(Number)) + } + it('returns 401 without a token', async () => { const res = await app.inject({ method: 'GET', url: '/health/details' }) expect(res.statusCode).toBe(401) @@ -40,7 +66,7 @@ describe('health API', () => { expect(res.statusCode).toBe(403) }) - it('returns 200 for admin role', async () => { + it('returns 200 with system details for admin role', async () => { const token = await registerAdminAndLogin(app) const res = await app.inject({ method: 'GET', @@ -48,9 +74,10 @@ describe('health API', () => { headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(200) + expectHealthDetails(res.json()) }) - it('returns 200 for super-admin role', async () => { + it('returns 200 with system details for super-admin role', async () => { const token = await registerSuperAdminAndLogin(app) const res = await app.inject({ method: 'GET', @@ -58,6 +85,7 @@ describe('health API', () => { headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(200) + expectHealthDetails(res.json()) }) }) }) diff --git a/src/tests/modules/permissions.test.ts b/src/tests/modules/permissions.test.ts index 262a323..b1824bb 100644 --- a/src/tests/modules/permissions.test.ts +++ b/src/tests/modules/permissions.test.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { SEED_PERMISSIONS } from '@/db/seed.js' import { createTestApp, registerAdminAndLogin, registerAndLogin, registerSuperAdminAndLogin, resetDb } from '@/tests/fixtures/index.js' interface Permission { @@ -57,10 +58,10 @@ describe('permissions API', () => { expect(res.statusCode).toBe(200) }) - it('returns all 15 seeded permissions', async () => { + it('returns every seeded permission', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/permissions', headers: auth(adminToken) }) const { data } = res.json<{ data: Permission[] }>() - expect(data).toHaveLength(20) + expect(data).toHaveLength(SEED_PERMISSIONS.length) }) it('returns permissions with correct shape', async () => { @@ -79,23 +80,7 @@ describe('permissions API', () => { const { data } = res.json<{ data: Permission[] }>() const keys = data.map(p => `${p.resource}:${p.action}:${p.scope}`) - const expected = [ - 'user:create:any', - 'user:read:any', - 'user:update:any', - 'user:delete:any', - 'user:read:own', - 'user:update:own', - 'role:create:any', - 'role:read:any', - 'role:update:any', - 'role:delete:any', - 'permission:create:any', - 'permission:read:any', - 'permission:update:any', - 'permission:delete:any', - 'audit-log:read:any', - ] + const expected = SEED_PERMISSIONS.map(p => `${p.resource}:${p.action}:${p.scope}`) for (const key of expected) { expect(keys).toContain(key) From 252c6ef7303c2f102bba350d99b5115b2c4cd641 Mon Sep 17 00:00:00 2001 From: Ruje Alfon Date: Sun, 28 Jun 2026 08:52:02 +0800 Subject: [PATCH 17/22] Update environment configuration and documentation for rate limiting in production --- .env.example | 1 + README.md | 6 ++++-- src/modules/auth/README.md | 2 +- src/plugins/README.md | 2 +- src/plugins/rate-limit.ts | 6 +++--- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 18273ba..5e262b9 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ PORT=3000 HOST=0.0.0.0 +# Rate limiting is enabled only when NODE_ENV=production. NODE_ENV=development DATABASE_URL=postgres://postgres:password@localhost:5432/fastify_dev JWT_SECRET=change_this_to_a_random_string_at_least_32_characters diff --git a/README.md b/README.md index 0db053d..845f783 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ cp .env.example .env | `TEST_DATABASE_URL` | | *(empty)* | PostgreSQL connection string used by test runs | | `PORT` | | `3000` | Server port | | `HOST` | | `0.0.0.0` | Server host | -| `NODE_ENV` | | `development` | `development` \| `production` \| `test` | +| `NODE_ENV` | | `development` | `development` \| `production` \| `test`. Rate limiting is enabled only in production. | | `LOG_LEVEL` | | `info` | Pino log level | | `COOKIE_SECRET` | | *(JWT_SECRET)* | Secret for signed cookies — falls back to `JWT_SECRET` if empty | | `OTEL_ENDPOINT` | | *(disabled)* | OTLP HTTP endpoint (e.g. `http://localhost:4318/v1/traces`). Leave empty to disable tracing. | @@ -128,6 +128,8 @@ All scripts run via `nub` (or `nubx` inside containers). See [package.json](pack | POST | `/api/v1/auth/mobile/login` | `x-mobile-api-key` | Login for mobile — returns `token` in body, no cookie | | POST | `/api/v1/auth/logout` | — | Logout — clears the `token` cookie | +Auth entry points (`register`, `login`, and `mobile/login`) are rate-limited to 5 requests per 15 minutes per client key in production. Development and test runs skip rate limiting so local API clients and integration tests are not throttled. + ### Users | Method | Path | Permission | Description | @@ -361,7 +363,7 @@ Errors from the server surface as `RpcError` (with `.status` and `.data`) on the - **Zod is the single source of truth** for types — no manual interfaces. All types are derived via `z.infer<>` from schemas in each module's `schemas/index.ts`. - **Services have no Fastify imports** — they receive `db` as a parameter, making them independently testable. - **Error handling** is centralized in `app.ts` via `setErrorHandler`. All domain errors extend `AppError`. -- **Rate limiting** uses Redis as the store — safe for multi-instance / horizontally scaled deployments. +- **Rate limiting** uses Redis as the store — safe for multi-instance / horizontally scaled deployments. It is active only when `NODE_ENV=production`; development and test runs skip it. - **Request context** (`@fastify/request-context`) stores `requestId`, `userId`, `permissions`, and `isSuperAdmin` via AsyncLocalStorage, accessible anywhere in the call stack without passing them explicitly. - **Audit logging** is fire-and-forget (`logAudit` in `src/modules/audit-logs/helpers/`) — inserts never block the request path. Failures are silently swallowed so a logging error never surfaces to the caller. - **Graceful shutdown** is handled in `server.ts` — `SIGINT`/`SIGTERM` closes Fastify (draining connections) and flushes OpenTelemetry spans before exiting. diff --git a/src/modules/auth/README.md b/src/modules/auth/README.md index 07d3ed7..2c35810 100644 --- a/src/modules/auth/README.md +++ b/src/modules/auth/README.md @@ -51,4 +51,4 @@ Authentication module — user registration and login. ## Password hashing -`bcryptjs` with the default salt rounds (10). Hashing happens in the service layer; controllers never touch raw passwords beyond passing the request body through. +`bcryptjs` with 12 salt rounds. Hashing happens in the service layer; controllers never touch raw passwords beyond passing the request body through. diff --git a/src/plugins/README.md b/src/plugins/README.md index a46b8fe..8e79022 100644 --- a/src/plugins/README.md +++ b/src/plugins/README.md @@ -36,7 +36,7 @@ requestIdHook ← writes requestId into request context | `cors.ts` | `@fastify/cors` | CORS — production-restricted by `NODE_ENV` | | `cookie.ts` | `@fastify/cookie` | Signed cookie parsing (`COOKIE_SECRET` \| `JWT_SECRET`) | | `redis.ts` | `@fastify/redis` | `fastify.redis` — shared ioredis client | -| `rate-limit.ts` | `@fastify/rate-limit` | Per-IP rate limiting (Redis store, 100 req / 15 min) | +| `rate-limit.ts` | `@fastify/rate-limit` | Production-only per-IP rate limiting (Redis store, 100 req / 15 min) | | `db.ts` | `drizzle-orm` | `fastify.db` — typed Drizzle ORM instance | | `under-pressure.ts` | `@fastify/under-pressure` | Auto-503 when heap > 200 MB, RSS > 300 MB, or loop delay > 1 s | | `multipart.ts` | `@fastify/multipart` | File upload support (10 MB / file, max 10 files) | diff --git a/src/plugins/rate-limit.ts b/src/plugins/rate-limit.ts index 42edc7a..aef631f 100644 --- a/src/plugins/rate-limit.ts +++ b/src/plugins/rate-limit.ts @@ -3,9 +3,9 @@ import rateLimit from '@fastify/rate-limit' import fp from 'fastify-plugin' const rateLimitPlugin: FastifyPluginAsync = async (fastify) => { - // Skip only in test — rate limits would break integration tests that hit - // auth endpoints many times and use real Redis with shared counters. - if (fastify.config.NODE_ENV === 'test') + // Keep local development and integration tests unthrottled. Production uses + // Redis-backed limits so counters are shared across app instances. + if (fastify.config.NODE_ENV !== 'production') return await fastify.register(rateLimit, { From 346c2c8548e0fcb47413676eb56c952d1d3d2079 Mon Sep 17 00:00:00 2001 From: Ruje Alfon Date: Sun, 28 Jun 2026 09:26:30 +0800 Subject: [PATCH 18/22] Refactor user role assignment in registerUser function to use transactions and update role constant reference --- README.md | 4 ++++ src/modules/auth/services/auth.service.ts | 14 ++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 845f783..0cfb03c 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,10 @@ Each log entry records `action`, `resource_type`, `resource_id`, `metadata`, and > **Production note:** `/metrics` should be restricted at the network/gateway level and not exposed publicly. +### Deployment Notes + +In production, rate-limit keys prefer the leftmost `X-Forwarded-For` entry so clients behind a reverse proxy are keyed by their originating IP. Only allow trusted proxies or gateways to set or forward `X-Forwarded-For`; if clients can send that header directly to the app, they can spoof different IPs and evade per-IP limits. Deployments should strip inbound forwarding headers at the edge and re-add them from the trusted proxy layer. + ### Pagination All list endpoints accept `?page=1&limit=10` query parameters. diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 90e8b0a..be88691 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -2,7 +2,7 @@ import type { Db } from '@/db/index.js' import type { LoginBody, RegisterBody } from '@/modules/auth/schemas/index.js' import bcrypt from 'bcryptjs' import { and, eq, isNotNull, isNull } from 'drizzle-orm' -import { PG_UNIQUE_VIOLATION } from '@/common/constants/index.js' +import { PG_UNIQUE_VIOLATION, ROLES } from '@/common/constants/index.js' import { ConflictError, UnauthorizedError } from '@/common/errors/AppError.js' import { profiles, roles, userRoles, users } from '@/db/schema/index.js' import { logAudit } from '@/modules/audit-logs/helpers/log-audit.js' @@ -30,10 +30,12 @@ export async function registerUser(db: Db, body: RegisterBody) { const dead = await db.query.users.findFirst({ where: and(eq(users.email, body.email), isNotNull(users.deletedAt)) }) if (dead) { if (await bcrypt.compare(body.password, dead.passwordHash)) { - await db.update(users).set({ deletedAt: null, deletedBy: null }).where(eq(users.id, dead.id)) - const [userRole] = await db.select({ id: roles.id }).from(roles).where(eq(roles.name, 'user')).limit(1) - if (userRole) - await db.insert(userRoles).values({ userId: dead.id, roleId: userRole.id }).onConflictDoNothing() + await db.transaction(async (tx) => { + await tx.update(users).set({ deletedAt: null, deletedBy: null }).where(eq(users.id, dead.id)) + const [userRole] = await tx.select({ id: roles.id }).from(roles).where(eq(roles.name, ROLES.USER)).limit(1) + if (userRole) + await tx.insert(userRoles).values({ userId: dead.id, roleId: userRole.id }).onConflictDoNothing() + }) logAudit(db, { userId: dead.id, action: 'auth.account_restored', resourceType: 'user', resourceId: dead.id }) return { id: dead.id, email: dead.email } } @@ -52,7 +54,7 @@ export async function registerUser(db: Db, body: RegisterBody) { await tx.insert(profiles).values({ userId: row.id }) // Assign the default 'user' role if seed has been run - const [userRole] = await tx.select({ id: roles.id }).from(roles).where(eq(roles.name, 'user')).limit(1) + const [userRole] = await tx.select({ id: roles.id }).from(roles).where(eq(roles.name, ROLES.USER)).limit(1) if (userRole) await tx.insert(userRoles).values({ userId: row.id, roleId: userRole.id }) From dd9b33b0336c2e6cdb9b8b135717f275979e827b Mon Sep 17 00:00:00 2001 From: Ruje Alfon Date: Mon, 29 Jun 2026 09:42:00 +0800 Subject: [PATCH 19/22] fix: remove duplicate registration of rateLimitPlugin in app setup --- .../0009_noop_admin_role_not_system.sql | 37 ++++++++++++++++++- src/app.ts | 1 - 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/migrations/0009_noop_admin_role_not_system.sql b/migrations/0009_noop_admin_role_not_system.sql index b850812..8628d31 100644 --- a/migrations/0009_noop_admin_role_not_system.sql +++ b/migrations/0009_noop_admin_role_not_system.sql @@ -1,2 +1,35 @@ --- no-op: admin role is not a system role; only super-admin is -SELECT 1; +INSERT INTO "permissions" ("id", "resource", "action", "scope") +VALUES + (gen_random_uuid(), 'product', 'read', 'any'), + (gen_random_uuid(), 'product', 'create', 'any'), + (gen_random_uuid(), 'product', 'update', 'any'), + (gen_random_uuid(), 'product', 'delete', 'any'), + (gen_random_uuid(), 'metrics', 'read', 'any'), + (gen_random_uuid(), 'health', 'read', 'details') +ON CONFLICT ("resource", "action", "scope") DO NOTHING; +--> statement-breakpoint +INSERT INTO "role_permissions" ("role_id", "permission_id") +SELECT r."id", p."id" +FROM "roles" r +JOIN ( + VALUES + ('product', 'read', 'any'), + ('product', 'create', 'any'), + ('product', 'update', 'any'), + ('product', 'delete', 'any'), + ('metrics', 'read', 'any'), + ('health', 'read', 'details') +) AS added_permissions("resource", "action", "scope") + ON true +JOIN "permissions" p ON ( + p."resource" = added_permissions."resource" + AND p."action" = added_permissions."action" + AND p."scope" = added_permissions."scope" +) +WHERE r."name" = 'super-admin' + OR (r."name" = 'admin' AND ( + added_permissions."resource" = 'product' + OR added_permissions."resource" = 'health' + )) + OR (r."name" = 'user' AND added_permissions."resource" = 'product' AND added_permissions."action" = 'read') +ON CONFLICT DO NOTHING; diff --git a/src/app.ts b/src/app.ts index 831d95a..ece44d8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -68,7 +68,6 @@ export async function buildApp() { await fastify.register(valkeyPlugin) await fastify.register(rateLimitPlugin) await fastify.register(dbPlugin) - await fastify.register(rateLimitPlugin) // Reliability await fastify.register(underPressurePlugin) From d71c79b97db7598a2af20837605cc76180315b43 Mon Sep 17 00:00:00 2001 From: Ruje Alfon Date: Mon, 29 Jun 2026 19:51:39 +0800 Subject: [PATCH 20/22] feat: add TRUST_PROXY configuration and update related documentation --- .env.example | 3 + README.md | 1 + src/app.ts | 38 ++++++++++++- src/config/README.md | 1 + src/config/schema.ts | 2 + src/contract/schemas/audit-logs.ts | 2 + src/contract/schemas/auth.ts | 5 ++ src/contract/schemas/permissions.ts | 1 + src/contract/schemas/products.ts | 18 ++++-- src/contract/schemas/profile.ts | 1 + src/contract/schemas/roles.ts | 7 +++ src/contract/schemas/users.ts | 7 +++ src/modules/auth/routes/index.ts | 3 +- src/modules/auth/schemas/index.ts | 3 +- src/modules/auth/services/auth.service.ts | 7 ++- src/modules/health/routes/index.ts | 1 + src/plugins/cors.ts | 2 +- src/plugins/rate-limit.ts | 11 +--- src/tests/modules/auth.test.ts | 25 +++++++- src/tests/modules/products.test.ts | 55 ++++++++++++++++++ src/tests/modules/proxy.test.ts | 69 +++++++++++++++++++++++ 21 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 src/tests/modules/proxy.test.ts diff --git a/.env.example b/.env.example index c29bd73..b94c058 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ MOBILE_API_KEY=change_this_to_a_random_string_at_least_32_characters # Required in production — comma-separated list of allowed CORS origins # Example: CORS_ORIGIN=https://app.example.com,https://admin.example.com CORS_ORIGIN= +# Set in production behind a reverse proxy so request.ip uses the real client IP. +# Examples: TRUST_PROXY=127.0.0.1, TRUST_PROXY=10.0.0.0/8, TRUST_PROXY=1 +TRUST_PROXY= diff --git a/README.md b/README.md index 65138d2..857c611 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ cp .env.example .env | `LOG_LEVEL` | | `info` | Pino log level | | `COOKIE_SECRET` | | *(JWT_SECRET)* | Secret for signed cookies — falls back to `JWT_SECRET` if empty | | `OTEL_ENDPOINT` | | *(disabled)* | OTLP HTTP endpoint (e.g. `http://localhost:4318/v1/traces`). Leave empty to disable tracing. | +| `TRUST_PROXY` | | *(disabled)* | Fastify trusted proxy setting for production client IPs, e.g. `127.0.0.1`, `10.0.0.0/8`, or `1` trusted hop | ## API Documentation diff --git a/src/app.ts b/src/app.ts index ece44d8..2091c0c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { existsSync, readFileSync } from 'node:fs' import process from 'node:process' import envPlugin from '@fastify/env' import Fastify from 'fastify' @@ -31,8 +32,34 @@ import sensiblePlugin from './plugins/sensible.js' import underPressurePlugin from './plugins/under-pressure.js' import valkeyPlugin from './plugins/valkey.js' +function parseTrustProxy(value: string | undefined) { + if (!value) + return undefined + const normalized = value.trim().toLowerCase() + if (normalized === 'true') + return true + if (normalized === 'false') + return undefined + if (/^\d+$/.test(normalized)) + return Number.parseInt(normalized, 10) + return value +} + +function readDotEnvValue(key: string) { + if (!existsSync('.env')) + return undefined + const prefix = `${key}=` + const line = readFileSync('.env', 'utf8') + .split(/\r?\n/) + .find(line => line.trim().startsWith(prefix)) + const value = line?.trim().slice(prefix.length).trim() + return value?.replace(/^["']|["']$/g, '') +} + export async function buildApp() { + const trustProxy = parseTrustProxy(process.env.TRUST_PROXY ?? readDotEnvValue('TRUST_PROXY')) const fastify = Fastify({ + ...(trustProxy !== undefined && { trustProxy }), logger: { level: process.env.LOG_LEVEL ?? 'info', transport: @@ -90,7 +117,7 @@ export async function buildApp() { if (error instanceof AppError) { return reply.status(error.statusCode).send(error.toJSON()) } - const err = error as Error & { validation?: Array> } + const err = error as Error & { code?: string, statusCode?: number, validation?: Array> } if (err.validation) { return reply.status(400).send({ success: false, @@ -110,6 +137,15 @@ export async function buildApp() { }, }) } + if (err.statusCode === 429) { + return reply.status(429).send({ + success: false, + error: { + code: err.code ?? 'HTTP_ERROR', + message: err.message, + }, + }) + } request.log.error({ err }, 'unhandled error') return reply.status(500).send({ success: false, diff --git a/src/config/README.md b/src/config/README.md index 13df43a..fa9d0df 100644 --- a/src/config/README.md +++ b/src/config/README.md @@ -45,3 +45,4 @@ No other files need to change — `fastify.config.MY_VAR` is immediately availab | `LOG_LEVEL` | `info` | Pino log levels | | `COOKIE_SECRET` | *(empty → JWT_SECRET)* | Secret for signed cookies | | `OTEL_ENDPOINT` | *(empty → disabled)* | OTLP HTTP trace exporter URL | +| `TRUST_PROXY` | *(empty → disabled)* | Fastify trusted proxy setting for deriving `request.ip` behind a reverse proxy | diff --git a/src/config/schema.ts b/src/config/schema.ts index 5ae28ef..0fd3200 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -17,6 +17,7 @@ export interface AppConfig { OTEL_ENDPOINT: string MOBILE_API_KEY: string CORS_ORIGIN: string + TRUST_PROXY: string } export const configSchema = { @@ -39,5 +40,6 @@ export const configSchema = { OTEL_ENDPOINT: { type: 'string', default: '' }, MOBILE_API_KEY: { type: 'string' }, CORS_ORIGIN: { type: 'string', default: '' }, + TRUST_PROXY: { type: 'string', default: '' }, }, } as const diff --git a/src/contract/schemas/audit-logs.ts b/src/contract/schemas/audit-logs.ts index d271523..297005d 100644 --- a/src/contract/schemas/audit-logs.ts +++ b/src/contract/schemas/audit-logs.ts @@ -14,6 +14,7 @@ export const auditLogsSchema = { 200: apiListSchema(auditLogSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, listForUser: { @@ -27,6 +28,7 @@ export const auditLogsSchema = { 200: apiListSchema(auditLogSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/auth.ts b/src/contract/schemas/auth.ts index b3dd6d7..984b77e 100644 --- a/src/contract/schemas/auth.ts +++ b/src/contract/schemas/auth.ts @@ -17,6 +17,8 @@ export const authSchema = { body: registerBodySchema, responses: { 201: apiSuccessSchema(authUserSchema), + 400: apiErrorSchema, + 429: apiErrorSchema, 409: apiErrorSchema, }, }, @@ -29,6 +31,7 @@ export const authSchema = { responses: { 200: apiSuccessSchema(authUserSchema), 401: apiErrorSchema, + 429: apiErrorSchema, }, }, mobileLogin: { @@ -41,6 +44,7 @@ export const authSchema = { 200: apiSuccessSchema(loginResponseSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, logout: { @@ -50,6 +54,7 @@ export const authSchema = { optionalAuth: true, responses: { 200: apiSuccessSchema(z.null()), + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/permissions.ts b/src/contract/schemas/permissions.ts index 9bcd38c..28b7caf 100644 --- a/src/contract/schemas/permissions.ts +++ b/src/contract/schemas/permissions.ts @@ -13,6 +13,7 @@ export const permissionsSchema = { 200: apiListSchema(permissionSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/products.ts b/src/contract/schemas/products.ts index 42b7ee3..793debf 100644 --- a/src/contract/schemas/products.ts +++ b/src/contract/schemas/products.ts @@ -1,5 +1,6 @@ import type { RouteMap } from '@/contract/types.js' import { z } from 'zod' +import { PERMISSIONS } from '@/common/constants/index.js' import { apiErrorSchema, apiListSchema, apiSuccessSchema, paginationQuerySchema, uuidParamSchema } from '@/common/schemas/index.js' import { createProductBodySchema, @@ -12,42 +13,47 @@ export const productsSchema = { method: 'GET' as const, path: '/api/v1/products', tags: ['Products'], - auth: true, + permission: PERMISSIONS.PRODUCT.READ_ANY, query: paginationQuerySchema, responses: { 200: apiListSchema(productSchema), 401: apiErrorSchema, + 403: apiErrorSchema, + 429: apiErrorSchema, }, }, get: { method: 'GET' as const, path: '/api/v1/products/:id', tags: ['Products'], - auth: true, + permission: PERMISSIONS.PRODUCT.READ_ANY, params: uuidParamSchema, responses: { 200: apiSuccessSchema(productSchema), 401: apiErrorSchema, + 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, create: { method: 'POST' as const, path: '/api/v1/products', tags: ['Products'], - permission: 'product:create:any', + permission: PERMISSIONS.PRODUCT.CREATE_ANY, body: createProductBodySchema, responses: { 201: apiSuccessSchema(productSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, update: { method: 'PATCH' as const, path: '/api/v1/products/:id', tags: ['Products'], - permission: 'product:update:any', + permission: PERMISSIONS.PRODUCT.UPDATE_ANY, params: uuidParamSchema, body: updateProductBodySchema, responses: { @@ -55,19 +61,21 @@ export const productsSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, delete: { method: 'DELETE' as const, path: '/api/v1/products/:id', tags: ['Products'], - permission: 'product:delete:any', + permission: PERMISSIONS.PRODUCT.DELETE_ANY, params: uuidParamSchema, responses: { 204: z.null(), 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/profile.ts b/src/contract/schemas/profile.ts index 975d0e9..2c115bc 100644 --- a/src/contract/schemas/profile.ts +++ b/src/contract/schemas/profile.ts @@ -11,6 +11,7 @@ export const profileSchema = { responses: { 200: apiSuccessSchema(userSchema), 401: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/roles.ts b/src/contract/schemas/roles.ts index 0e3f862..67e7b27 100644 --- a/src/contract/schemas/roles.ts +++ b/src/contract/schemas/roles.ts @@ -19,6 +19,7 @@ export const rolesSchema = { 200: apiListSchema(roleSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, get: { @@ -32,6 +33,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, create: { @@ -45,6 +47,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 409: apiErrorSchema, + 429: apiErrorSchema, }, }, update: { @@ -60,6 +63,7 @@ export const rolesSchema = { 403: apiErrorSchema, 404: apiErrorSchema, 409: apiErrorSchema, + 429: apiErrorSchema, }, }, delete: { @@ -73,6 +77,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, assignPermission: { @@ -86,6 +91,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, removePermission: { @@ -99,6 +105,7 @@ export const rolesSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/contract/schemas/users.ts b/src/contract/schemas/users.ts index 89728e6..c025702 100644 --- a/src/contract/schemas/users.ts +++ b/src/contract/schemas/users.ts @@ -24,6 +24,7 @@ export const usersSchema = { 200: apiListSchema(userSchema), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, get: { @@ -37,6 +38,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, create: { @@ -50,6 +52,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 409: apiErrorSchema, + 429: apiErrorSchema, }, }, update: { @@ -65,6 +68,7 @@ export const usersSchema = { 403: apiErrorSchema, 404: apiErrorSchema, 409: apiErrorSchema, + 429: apiErrorSchema, }, }, delete: { @@ -78,6 +82,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, assignRole: { @@ -91,6 +96,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, removeRole: { @@ -104,6 +110,7 @@ export const usersSchema = { 401: apiErrorSchema, 403: apiErrorSchema, 404: apiErrorSchema, + 429: apiErrorSchema, }, }, } satisfies RouteMap diff --git a/src/modules/auth/routes/index.ts b/src/modules/auth/routes/index.ts index 2575f60..915fe3f 100644 --- a/src/modules/auth/routes/index.ts +++ b/src/modules/auth/routes/index.ts @@ -34,7 +34,8 @@ export default createFastifyRpcPlugin(authSchema, { throw new ForbiddenError('Mobile login is restricted to mobile clients') // Hash both values to a fixed 32-byte digest before comparing so the // comparison is always constant-time and leaks neither key length nor content. - const provided = createHash('sha256').update(request.headers['x-mobile-api-key'] as string ?? '').digest() + const mobileApiKey = request.headers['x-mobile-api-key'] + const provided = createHash('sha256').update(Array.isArray(mobileApiKey) ? mobileApiKey[0] ?? '' : mobileApiKey ?? '').digest() const expected = createHash('sha256').update(request.server.config.MOBILE_API_KEY).digest() if (!timingSafeEqual(provided, expected)) throw new ForbiddenError('Mobile login is restricted to mobile clients') diff --git a/src/modules/auth/schemas/index.ts b/src/modules/auth/schemas/index.ts index 37401cf..447515e 100644 --- a/src/modules/auth/schemas/index.ts +++ b/src/modules/auth/schemas/index.ts @@ -1,9 +1,8 @@ import { z } from 'zod' -import { passwordSchema } from '@/common/schemas/index.js' export const registerBodySchema = z.object({ email: z.email().meta({ examples: ['alice@example.com'] }), - password: passwordSchema.meta({ examples: ['SecurePassword1'] }), + password: z.string().min(1).max(72).meta({ examples: ['SecurePassword1'] }), }) export const loginBodySchema = z.object({ diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index be88691..94ed171 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -3,7 +3,8 @@ import type { LoginBody, RegisterBody } from '@/modules/auth/schemas/index.js' import bcrypt from 'bcryptjs' import { and, eq, isNotNull, isNull } from 'drizzle-orm' import { PG_UNIQUE_VIOLATION, ROLES } from '@/common/constants/index.js' -import { ConflictError, UnauthorizedError } from '@/common/errors/AppError.js' +import { AppError, ConflictError, UnauthorizedError } from '@/common/errors/AppError.js' +import { passwordSchema } from '@/common/schemas/index.js' import { profiles, roles, userRoles, users } from '@/db/schema/index.js' import { logAudit } from '@/modules/audit-logs/helpers/log-audit.js' @@ -42,6 +43,10 @@ export async function registerUser(db: Db, body: RegisterBody) { throw new ConflictError('An account with this email already exists') } + const password = passwordSchema.safeParse(body.password) + if (!password.success) + throw new AppError(400, 'VALIDATION_ERROR', password.error.issues[0]?.message ?? 'Invalid password') + const passwordHash = await bcrypt.hash(body.password, 12) try { diff --git a/src/modules/health/routes/index.ts b/src/modules/health/routes/index.ts index 85aa6e6..8c45710 100644 --- a/src/modules/health/routes/index.ts +++ b/src/modules/health/routes/index.ts @@ -46,6 +46,7 @@ const healthRoutes: FastifyPluginAsyncZod = async (fastify) => { })), 401: apiErrorSchema, 403: apiErrorSchema, + 429: apiErrorSchema, }, }, preValidation: [fastify.authenticate, fastify.requirePermission(PERMISSIONS.HEALTH.READ_DETAILS)], diff --git a/src/plugins/cors.ts b/src/plugins/cors.ts index 840ecc7..c431c27 100644 --- a/src/plugins/cors.ts +++ b/src/plugins/cors.ts @@ -18,7 +18,7 @@ const corsPlugin: FastifyPluginAsync = async (fastify) => { await fastify.register(cors, { origin, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Mobile-Api-Key'], credentials: true, }) } diff --git a/src/plugins/rate-limit.ts b/src/plugins/rate-limit.ts index e08a4ec..d7563d8 100644 --- a/src/plugins/rate-limit.ts +++ b/src/plugins/rate-limit.ts @@ -10,17 +10,12 @@ const rateLimitPlugin: FastifyPluginAsync = async (fastify) => { return await fastify.register(rateLimit, { + global: true, max: 100, timeWindow: '15 minutes', store: createValkeyRateLimitStore(fastify.valkey), - // Parse the leftmost entry from x-forwarded-for so clients behind a trusted - // reverse proxy are keyed by their real IP. Do not trust the full header - // string as a key — a single client can send multiple IPs in the chain. - keyGenerator: (request) => { - const xff = request.headers['x-forwarded-for'] - const raw = Array.isArray(xff) ? xff[0] : xff - return raw?.split(',')[0]?.trim() ?? request.ip - }, + allowList: request => request.url === '/health/live' || request.url === '/health/ready', + keyGenerator: request => request.ip, }) } diff --git a/src/tests/modules/auth.test.ts b/src/tests/modules/auth.test.ts index ed7d77a..0cac9b2 100644 --- a/src/tests/modules/auth.test.ts +++ b/src/tests/modules/auth.test.ts @@ -1,7 +1,9 @@ import type { FastifyInstance } from 'fastify' +import bcrypt from 'bcryptjs' import { and, eq } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { auditLogs } from '@/db/schema/index.js' +import { ROLES } from '@/common/constants/index.js' +import { auditLogs, profiles, roles, userRoles, users } from '@/db/schema/index.js' import { createTestApp, eventually, extractTokenFromCookie, firstCookieHeader, registerAndLogin, registerAndLoginWithUser, resetDb } from '@/tests/fixtures/index.js' describe('auth API', () => { @@ -360,6 +362,15 @@ describe('auth API', () => { return created.id } + async function createLegacyUser(email: string, password: string) { + const [user] = await app.db.insert(users).values({ email, passwordHash: await bcrypt.hash(password, 12) }).returning({ id: users.id }) + await app.db.insert(profiles).values({ userId: user.id }) + const role = await app.db.query.roles.findFirst({ where: eq(roles.name, ROLES.USER) }) + if (role) + await app.db.insert(userRoles).values({ userId: user.id, roleId: role.id }) + return user.id + } + it('reactivates the same account on re-register with the correct password', async () => { const id = await registerThenDelete('erin@example.com') @@ -369,6 +380,18 @@ describe('auth API', () => { expect((await login('erin@example.com', 'Password123')).statusCode).toBe(200) }) + it('reactivates a legacy account with a correct weak password', async () => { + const email = 'legacy@example.com' + const password = 'password123' + const id = await createLegacyUser(email, password) + const token = extractTokenFromCookie((await login(email, password)).headers['set-cookie']) + await app.inject({ method: 'DELETE', url: `/api/v1/users/${id}`, headers: { authorization: `Bearer ${token}` } }) + + const again = await register(email, password) + expect(again.statusCode).toBe(201) + expect(again.json<{ data: { id: string } }>().data.id).toBe(id) + }) + it('restores the account\'s profile data on reactivation', async () => { const email = 'iris@example.com' const { data: created } = (await register(email, 'Password123')).json<{ data: { id: string } }>() diff --git a/src/tests/modules/products.test.ts b/src/tests/modules/products.test.ts index 55e5d17..d41f1dc 100644 --- a/src/tests/modules/products.test.ts +++ b/src/tests/modules/products.test.ts @@ -1,5 +1,8 @@ import type { FastifyInstance } from 'fastify' +import { and, eq } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { PERMISSIONS } from '@/common/constants/index.js' +import { permissions, rolePermissions, roles } from '@/db/schema/index.js' import { createTestApp, registerAdminAndLogin, registerAndLogin, resetDb } from '@/tests/fixtures/index.js' describe('products API', () => { @@ -94,6 +97,58 @@ describe('products API', () => { userToken = await registerAndLogin(app, { email: 'regular@example.com', password: 'Password123' }) }) + async function revokeProductReadPermission() { + const [resource, action, scope] = PERMISSIONS.PRODUCT.READ_ANY.split(':') + const [role] = await app.db.select({ id: roles.id }).from(roles).where(eq(roles.name, 'user')).limit(1) + const [permission] = await app.db + .select({ id: permissions.id }) + .from(permissions) + .where(and( + eq(permissions.resource, resource), + eq(permissions.action, action), + eq(permissions.scope, scope), + )) + .limit(1) + if (!role || !permission) + throw new Error('seeded user role or product read permission not found') + + await app.db + .delete(rolePermissions) + .where(and( + eq(rolePermissions.roleId, role.id), + eq(rolePermissions.permissionId, permission.id), + )) + } + + it('returns 403 for GET /api/v1/products without product read permission', async () => { + await revokeProductReadPermission() + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/products', + headers: { authorization: `Bearer ${userToken}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('returns 403 for GET /api/v1/products/:id without product read permission', async () => { + const create = await app.inject({ + method: 'POST', + url: '/api/v1/products', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Gadget', price: 19.99, stock: 50 }, + }) + const { data: created } = create.json<{ data: { id: string } }>() + await revokeProductReadPermission() + + const res = await app.inject({ + method: 'GET', + url: `/api/v1/products/${created.id}`, + headers: { authorization: `Bearer ${userToken}` }, + }) + expect(res.statusCode).toBe(403) + }) + it('returns 403 for POST /api/v1/products', async () => { const res = await app.inject({ method: 'POST', diff --git a/src/tests/modules/proxy.test.ts b/src/tests/modules/proxy.test.ts new file mode 100644 index 0000000..80f8c91 --- /dev/null +++ b/src/tests/modules/proxy.test.ts @@ -0,0 +1,69 @@ +import type { FastifyInstance } from 'fastify' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { auditLogs } from '@/db/schema/index.js' +import { createTestApp, eventually, resetDb } from '@/tests/fixtures/index.js' + +describe('trusted proxy handling', () => { + let app: FastifyInstance + let originalCwd: string + let originalTrustProxy: string | undefined + let tempDir: string + + beforeAll(async () => { + originalCwd = process.cwd() + originalTrustProxy = process.env.TRUST_PROXY + delete process.env.TRUST_PROXY + tempDir = mkdtempSync(join(tmpdir(), 'fastify-api-proxy-')) + writeFileSync(join(tempDir, '.env'), 'TRUST_PROXY=127.0.0.1\n') + process.chdir(tempDir) + app = await createTestApp() + }) + + beforeEach(async () => { + await resetDb(app) + }) + + afterAll(async () => { + await app.close() + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + if (originalTrustProxy === undefined) + delete process.env.TRUST_PROXY + else + process.env.TRUST_PROXY = originalTrustProxy + }) + + it('uses TRUST_PROXY from .env for request.ip', async () => { + await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { email: 'proxy@example.com', password: 'Password123' }, + }) + + await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'x-forwarded-for': '203.0.113.7' }, + payload: { email: 'proxy@example.com', password: 'Password123' }, + }) + + const log = await eventually( + async () => { + const [row] = await app.db + .select({ metadata: auditLogs.metadata }) + .from(auditLogs) + .where(eq(auditLogs.action, 'auth.logged_in')) + .limit(1) + return row + }, + row => row !== undefined, + ) + + expect(log.metadata).toMatchObject({ ip: '203.0.113.7' }) + }) +}) From 7a9a29c7eb016fbad7d56468637fe544c11a4c09 Mon Sep 17 00:00:00 2001 From: Ruje Alfon Date: Mon, 29 Jun 2026 20:07:45 +0800 Subject: [PATCH 21/22] fix: improve .env value parsing and update proxy test setup --- src/app.ts | 9 ++++++++- src/modules/auth/schemas/index.ts | 5 ++++- src/tests/modules/proxy.test.ts | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index 2091c0c..88ea337 100644 --- a/src/app.ts +++ b/src/app.ts @@ -53,7 +53,14 @@ function readDotEnvValue(key: string) { .split(/\r?\n/) .find(line => line.trim().startsWith(prefix)) const value = line?.trim().slice(prefix.length).trim() - return value?.replace(/^["']|["']$/g, '') + if (!value) + return undefined + const quote = value[0] + if (quote === '"' || quote === '\'') { + const end = value.indexOf(quote, 1) + return end === -1 ? value.slice(1) : value.slice(1, end) + } + return value.replace(/\s+#.*$/, '').trim() } export async function buildApp() { diff --git a/src/modules/auth/schemas/index.ts b/src/modules/auth/schemas/index.ts index 447515e..0884c66 100644 --- a/src/modules/auth/schemas/index.ts +++ b/src/modules/auth/schemas/index.ts @@ -2,7 +2,10 @@ import { z } from 'zod' export const registerBodySchema = z.object({ email: z.email().meta({ examples: ['alice@example.com'] }), - password: z.string().min(1).max(72).meta({ examples: ['SecurePassword1'] }), + password: z.string().min(1).max(72).meta({ + description: 'New accounts require uppercase, lowercase, and digit; legacy account reactivation may use the original password.', + examples: ['SecurePassword1'], + }), }) export const loginBodySchema = z.object({ diff --git a/src/tests/modules/proxy.test.ts b/src/tests/modules/proxy.test.ts index 80f8c91..b9b7a79 100644 --- a/src/tests/modules/proxy.test.ts +++ b/src/tests/modules/proxy.test.ts @@ -19,7 +19,7 @@ describe('trusted proxy handling', () => { originalTrustProxy = process.env.TRUST_PROXY delete process.env.TRUST_PROXY tempDir = mkdtempSync(join(tmpdir(), 'fastify-api-proxy-')) - writeFileSync(join(tempDir, '.env'), 'TRUST_PROXY=127.0.0.1\n') + writeFileSync(join(tempDir, '.env'), 'TRUST_PROXY=127.0.0.1 # local reverse proxy\n') process.chdir(tempDir) app = await createTestApp() }) From e08d5357993f472467778fae74da64a402a06239 Mon Sep 17 00:00:00 2001 From: Ruje Alfon Date: Mon, 29 Jun 2026 20:31:11 +0800 Subject: [PATCH 22/22] feat: add CORS_ORIGIN and TRUST_PROXY environment variables to docker-compose and refactor app registration order --- docker-compose.yml | 2 ++ src/app.ts | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b25c903..b51a74f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,8 @@ services: OTEL_ENDPOINT: '' LOG_LEVEL: info MOBILE_API_KEY: change_this_to_a_random_string_at_least_32_characters + CORS_ORIGIN: '' + TRUST_PROXY: '' ports: - '3000:3000' volumes: diff --git a/src/app.ts b/src/app.ts index 88ea337..d7449ff 100644 --- a/src/app.ts +++ b/src/app.ts @@ -86,35 +86,35 @@ export async function buildApp() { dotenv: true, }) - // Core utilities - await fastify.register(sensiblePlugin) + // Data layer + await fastify.register(dbPlugin) + await fastify.register(valkeyPlugin) + await fastify.register(rateLimitPlugin) // Security & transport await fastify.register(helmetPlugin) - await fastify.register(compressPlugin) await fastify.register(corsPlugin) await fastify.register(cookiePlugin) + // Auth + await fastify.register(jwtPlugin) + await fastify.register(requestContextPlugin) + await fastify.register(mobileAuthPlugin) + await fastify.register(authDecorator) + await fastify.register(requestIdHook) + + // Core utilities + await fastify.register(sensiblePlugin) + await fastify.register(compressPlugin) + // API docs await fastify.register(scalarPlugin) - // Data layer - await fastify.register(valkeyPlugin) - await fastify.register(rateLimitPlugin) - await fastify.register(dbPlugin) - // Reliability await fastify.register(underPressurePlugin) // Request lifecycle await fastify.register(multipartPlugin) - await fastify.register(requestContextPlugin) - - // Auth - await fastify.register(jwtPlugin) - await fastify.register(mobileAuthPlugin) - await fastify.register(authDecorator) - await fastify.register(requestIdHook) // Observability — registered after auth so fastify.authenticate is available await fastify.register(metricsPlugin)