/** * Encryption at rest for customer personal data, applied to every Prisma client (db.server.ts). * * Booking: customerName, customerEmail, customerPhone, address (+ customerEmailHash, customerPhoneHash) * WaitlistEntry: name, email, phone (+ emailHash, phoneHash) * * Writes are sealed with AES-256-GCM in a query extension; reads are opened in a result extension, * which also covers nested reads (e.g. reminderJob → booking). Code that queries by email or phone * must use the *Hash columns with blindIndex(), because ciphertext can't be matched. */ import { Prisma } from "@prisma/client"; import { blindIndex, seal, unseal } from "../services/crypto.server"; type Data = Record; function sealAddress(v: unknown) { if (v == null || v === Prisma.DbNull || v === Prisma.JsonNull) return v; if (typeof v === "object" && v && "enc1" in v) return v; return { enc1: seal(JSON.stringify(v)) }; } function openAddress(v: unknown) { if (v && typeof v === "object" && "enc1" in v && typeof (v as { enc1: unknown }).enc1 === "string") { return JSON.parse(unseal((v as { enc1: string }).enc1) ?? "null"); } return v ?? null; } const str = (v: unknown) => (typeof v === "string" ? v : null); function sealBooking(d: Data | undefined) { if (!d) return d; const out: Data = { ...d }; if ("customerName" in d) out.customerName = seal(str(d.customerName)); if ("customerEmail" in d) { out.customerEmail = seal(str(d.customerEmail)); out.customerEmailHash = blindIndex(str(d.customerEmail), "email"); } if ("customerPhone" in d) { out.customerPhone = seal(str(d.customerPhone)); out.customerPhoneHash = blindIndex(str(d.customerPhone), "phone"); } if ("address" in d) out.address = sealAddress(d.address); return out; } function sealWaitlist(d: Data | undefined) { if (!d) return d; const out: Data = { ...d }; if ("name" in d) out.name = seal(str(d.name)); if ("email" in d) { out.email = seal(str(d.email)); out.emailHash = blindIndex(str(d.email), "email"); } if ("phone" in d) { out.phone = seal(str(d.phone)); out.phoneHash = blindIndex(str(d.phone), "phone"); } return out; } /** Applies a sealer to the data of create / update / upsert / createMany / updateMany. */ function sealArgs(args: Data, sealer: (d: Data | undefined) => Data | undefined) { const a: Data = { ...args }; if (Array.isArray(a.data)) a.data = (a.data as Data[]).map((x) => sealer(x)); else if (a.data) a.data = sealer(a.data as Data); if (a.create) a.create = sealer(a.create as Data); if (a.update) a.update = sealer(a.update as Data); return a; } const WRITES = new Set(["create", "createMany", "createManyAndReturn", "update", "updateMany", "updateManyAndReturn", "upsert"]); export const piiExtension = Prisma.defineExtension({ name: "datewise-pii", query: { booking: { $allOperations({ operation, args, query }) { return query(WRITES.has(operation) ? (sealArgs(args as Data, sealBooking) as typeof args) : args); }, }, waitlistEntry: { $allOperations({ operation, args, query }) { return query(WRITES.has(operation) ? (sealArgs(args as Data, sealWaitlist) as typeof args) : args); }, }, }, result: { booking: { customerName: { needs: { customerName: true }, compute: (b) => unseal(b.customerName) }, customerEmail: { needs: { customerEmail: true }, compute: (b) => unseal(b.customerEmail) }, customerPhone: { needs: { customerPhone: true }, compute: (b) => unseal(b.customerPhone) }, address: { needs: { address: true }, compute: (b) => openAddress(b.address) as Prisma.JsonValue }, }, waitlistEntry: { name: { needs: { name: true }, compute: (w) => unseal(w.name) }, email: { needs: { email: true }, compute: (w) => unseal(w.email) }, phone: { needs: { phone: true }, compute: (w) => unseal(w.phone) }, }, }, });