feat(db): encrypt Profile health fields at rest (ADR 0002)

Wire prisma-field-encryption AES-256-GCM extension on the shared Prisma
client and annotate the four sensitive columns on Profile with @encrypted:
- weightStartKg / weightCurrentKg / weightGoalKg (Decimal → String)
- targetDate (DateTime @db.Date → String, ISO YYYY-MM-DD)

Other Profile fields stay in clear text per ADR 0002 (age, gender,
heightCm, activityLevel) — they're needed for plan generation and
aggregate analytics, and are not strongly identifying on their own.

apps/api profile.routes.ts:
- serialize() now reads the columns as strings and parses them back to
  numbers for BMR/TDEE; targetDate is already an ISO string from the DB
- the upsert stringifies numeric inputs and slices the date to YYYY-MM-DD

Env wiring:
- packages/db, apps/api, apps/web .env.example all document
  PRISMA_FIELD_ENCRYPTION_KEY (k1.aesgcm256.<base64url>) — must match
  across every process that hits the DB
- key generation snippet documented inline

Migration is intentionally NOT in this commit: needs to be created against
a live Postgres instance and applied. The fields change Decimal/Date → text
so prisma migrate dev will require a USING cast — see the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 15:03:24 +02:00
1 parent f954be610b
commit 9555022143
8 files changed
+51 -23

No files matched your search

+3
View File
@@ -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=
+2 -1
View File
@@ -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:*",
+8 -4
View File
@@ -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")
+8 -2
View File
@@ -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());