diff --git a/apps/api/.env.example b/apps/api/.env.example index 523b11f..3f75a6e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -8,6 +8,11 @@ CORS_ORIGINS=http://localhost:3000 DATABASE_URL=postgresql://luciano:luciano%40@localhost:5432/ketopath?schema=public +# Cifratura at-rest dei campi sanitari (vedi ADR 0002). +# Genera con: node -e "console.log('k1.aesgcm256.'+require('crypto').generateKeySync('aes',{length:256}).export().toString('base64url'))" +# DEVE coincidere con apps/web/.env e con tutti i deploy che leggono il DB. +PRISMA_FIELD_ENCRYPTION_KEY= + # Better Auth — DEVE coincidere con apps/web/.env (sessione condivisa) BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3000 diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index 55475ba..7acfc58 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -10,21 +10,24 @@ import type { FastifyPluginAsync } from 'fastify'; import { requireAuth } from '../../plugins/auth.js'; +// The weight columns are encrypted at rest (ADR 0002) and therefore stored +// as String. The Zod input schema parses numeric strings → numbers, but the +// Prisma row keeps them as strings — so serialize/parse explicitly here. function serialize( profile: { age: number; gender: Gender; heightCm: number; - weightStartKg: { toNumber(): number }; - weightCurrentKg: { toNumber(): number }; - weightGoalKg: { toNumber(): number }; + weightStartKg: string; + weightCurrentKg: string; + weightGoalKg: string; activityLevel: ActivityLevel; - targetDate: Date | null; + targetDate: string | null; currentPhase: string; } | null, ) { if (!profile) return null; - const weightCurrentKg = profile.weightCurrentKg.toNumber(); + const weightCurrentKg = Number(profile.weightCurrentKg); const bmr = calculateBmr({ weightKg: weightCurrentKg, heightCm: profile.heightCm, @@ -36,11 +39,11 @@ function serialize( age: profile.age, gender: profile.gender, heightCm: profile.heightCm, - weightStartKg: profile.weightStartKg.toNumber(), + weightStartKg: Number(profile.weightStartKg), weightCurrentKg, - weightGoalKg: profile.weightGoalKg.toNumber(), + weightGoalKg: Number(profile.weightGoalKg), activityLevel: profile.activityLevel, - targetDate: profile.targetDate?.toISOString() ?? null, + targetDate: profile.targetDate, currentPhase: profile.currentPhase, derived: { bmr: Math.round(bmr), @@ -67,6 +70,8 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => { const data = parsed.data; const userId = request.user!.id; + const targetDateStr = data.targetDate ? data.targetDate.toISOString().slice(0, 10) : null; + const profile = await fastify.prisma.profile.upsert({ where: { userId }, create: { @@ -74,21 +79,21 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => { age: data.age, gender: data.gender, heightCm: data.heightCm, - weightStartKg: data.weightStartKg, - weightCurrentKg: data.weightCurrentKg, - weightGoalKg: data.weightGoalKg, + weightStartKg: String(data.weightStartKg), + weightCurrentKg: String(data.weightCurrentKg), + weightGoalKg: String(data.weightGoalKg), activityLevel: data.activityLevel, - targetDate: data.targetDate ?? null, + targetDate: targetDateStr, }, update: { age: data.age, gender: data.gender, heightCm: data.heightCm, - weightStartKg: data.weightStartKg, - weightCurrentKg: data.weightCurrentKg, - weightGoalKg: data.weightGoalKg, + weightStartKg: String(data.weightStartKg), + weightCurrentKg: String(data.weightCurrentKg), + weightGoalKg: String(data.weightGoalKg), activityLevel: data.activityLevel, - targetDate: data.targetDate ?? null, + targetDate: targetDateStr, }, }); diff --git a/apps/web/.env.example b/apps/web/.env.example index 096a9a4..77bb949 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -12,6 +12,10 @@ BETTER_AUTH_URL=http://localhost:3000 # Database (Better Auth usa @ketopath/db, che legge DATABASE_URL) DATABASE_URL=postgresql://luciano:luciano%40@localhost:5432/ketopath?schema=public +# Cifratura at-rest dei campi sanitari (vedi ADR 0002). +# DEVE essere identica a apps/api/.env. +PRISMA_FIELD_ENCRYPTION_KEY= + # Google OAuth (configurato a fine progetto) # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= diff --git a/packages/db/.env.example b/packages/db/.env.example index b9e34d3..a8973fd 100644 --- a/packages/db/.env.example +++ b/packages/db/.env.example @@ -1 +1,4 @@ DATABASE_URL="postgresql://luciano:luciano%40@localhost:5432/ketopath?schema=public" + +# Cifratura at-rest (vedi ADR 0002). Stesso valore in apps/api/.env e apps/web/.env. +PRISMA_FIELD_ENCRYPTION_KEY= diff --git a/packages/db/package.json b/packages/db/package.json index 6a34cf6..7eba908 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -19,7 +19,8 @@ "db:seed": "tsx prisma/seed.ts" }, "dependencies": { - "@prisma/client": "^5.18.0" + "@prisma/client": "^5.18.0", + "prisma-field-encryption": "^1.6.0" }, "devDependencies": { "@ketopath/eslint-config": "workspace:*", diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ce968fc..c956aa7 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -128,11 +128,15 @@ model Profile { age Int gender Gender heightCm Int @map("height_cm") - weightStartKg Decimal @map("weight_start_kg") @db.Decimal(5, 2) - weightCurrentKg Decimal @map("weight_current_kg") @db.Decimal(5, 2) - weightGoalKg Decimal @map("weight_goal_kg") @db.Decimal(5, 2) + /// @encrypted + weightStartKg String @map("weight_start_kg") + /// @encrypted + weightCurrentKg String @map("weight_current_kg") + /// @encrypted + weightGoalKg String @map("weight_goal_kg") activityLevel ActivityLevel @map("activity_level") - targetDate DateTime? @map("target_date") @db.Date + /// @encrypted + targetDate String? @map("target_date") currentPhase Phase @default(INTENSIVE) @map("current_phase") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 655e39f..6f6e4f3 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,13 +1,19 @@ import { PrismaClient } from '@prisma/client'; +import { fieldEncryptionExtension } from 'prisma-field-encryption'; +// Singleton-friendly storage of the BASE client (so HMR doesn't open a new pool +// every time). The exported `prisma` is the EXTENDED client returned by +// $extends — it wraps the base client with the field-encryption middleware. const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; -export const prisma: PrismaClient = +const baseClient = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], }); if (process.env.NODE_ENV !== 'production') { - globalForPrisma.prisma = prisma; + globalForPrisma.prisma = baseClient; } + +export const prisma = baseClient.$extends(fieldEncryptionExtension()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29dcf4c..929abfa 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ