From 1d2d943d54500715e27d99dbea26840b61a7b224 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Wed, 14 Oct 2026 18:56:05 -0400 Subject: [PATCH] feat(security): encrypt customer personal data at rest - Prisma extension seals booking name/email/phone/address and waitlist contacts with AES-256-GCM on write and opens them on read (incl. nested reads) - keyed-hash columns for exact email/phone lookups (privacy webhooks, booking search, waitlist de-duplication); migration 0002 - tests prove only ciphertext is stored; privacy/terms/SECURITY.md updated to match Co-Authored-By: Claude Opus 5.5 --- SECURITY.md | 40 +++++ app/db.server.ts | 9 +- app/lib/__tests__/pii.server.test.ts | 91 +++++++++++ app/lib/bookings-query.server.ts | 9 +- app/lib/pii.server.ts | 101 +++++++++++++ app/routes/app.bookings._index.tsx | 2 +- app/routes/privacy.tsx | 6 +- app/routes/terms.tsx | 2 +- app/routes/webhooks.compliance.tsx | 14 +- app/services/crypto.server.ts | 47 +++++- app/services/storefront.server.ts | 6 +- .../0002_pii_encryption/migration.sql | 8 + prisma/schema.prisma | 143 +++++++++--------- vitest.config.ts | 10 ++ 14 files changed, 398 insertions(+), 90 deletions(-) create mode 100644 SECURITY.md create mode 100644 app/lib/__tests__/pii.server.test.ts create mode 100644 app/lib/pii.server.ts create mode 100644 prisma/migrations/0002_pii_encryption/migration.sql create mode 100644 vitest.config.ts diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6df7eaa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Datewise security and incident response policy + +Owner: Metatroncube Software Solutions · Contact: info@metatroncubesolutions.com · Last reviewed: 2026-10-14 + +## Data we hold +Shop settings and scheduling rules; bookings (order ID and number, method, location, date, time, items); +customers' name, email, phone and delivery address for reminders and driver run sheets; waitlist emails +and phone numbers. Merchant Twilio / WhatsApp credentials when connected. + +## Data protection +- **In transit:** HTTPS only (TLS at Nginx or Caddy). +- **At rest:** customer name, email, phone and address (bookings and waitlist) and messaging credentials are + encrypted with AES-256-GCM in the application before they reach the database (`app/lib/pii.server.ts`). + Email and phone lookups use HMAC-SHA256 keyed hashes. The key (`APP_ENCRYPTION_KEY`) lives only in the + server's `.env` and the team password manager. +- **Backups:** nightly `pg_dump`, encrypted with AES-256 (`scripts/backup.sh`), kept 14 days. +- **Minimisation:** postal codes typed into the cart are used for zone lookup and not stored with identity. +- **Retention:** customer redaction requests clear that customer's details from bookings and delete their + waitlist entries; all shop data is deleted 48 hours after uninstall (`shop/redact`). +- Test and production are separate: development uses local databases and Shopify development stores. + +## Access control +- Production access (VPS, database, Shopify Partner/Dev Dashboard) is limited to named staff who need it. +- SSH uses key-based login; Shopify and Git accounts use strong unique passwords and two-factor authentication. +- Secrets (`.env`: Shopify secret, database password, `APP_ENCRYPTION_KEY`) are never stored in Git or chat. + Rotate them when someone with access leaves (the encryption key needs a re-encryption step; see below). +- Access is reviewed every quarter. + +## Incident response +1. **Detect and contain** (immediately): revoke exposed credentials, rotate secrets, block the access path, + take the affected service offline if needed. +2. **Assess** (within 24 h): what data, which shops, how many records, time window. Preserve logs. +3. **Notify** (within 72 h of becoming aware): affected merchants by email, and Shopify via the Partner + support channel; regulators where the law requires it. +4. **Recover**: fix the root cause, restore from encrypted backups if needed, verify. +5. **Review** (within 2 weeks): written post-mortem, follow-up actions, update this policy. + +## Key rotation +Changing `APP_ENCRYPTION_KEY` makes existing ciphertext unreadable. To rotate: deploy with both keys, +re-encrypt rows with a one-off script, then remove the old key. Never change the key on its own. diff --git a/app/db.server.ts b/app/db.server.ts index 4474532..d3d6019 100644 --- a/app/db.server.ts +++ b/app/db.server.ts @@ -1,5 +1,6 @@ import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; +import { piiExtension } from "./lib/pii.server"; declare global { // eslint-disable-next-line no-var @@ -8,11 +9,13 @@ declare global { /** * Rust-free Prisma client over node-postgres (no native engine binary needed, - * which also makes shared/VPS hosting simpler). + * which also makes shared/VPS hosting simpler). Every client encrypts customer + * personal data at rest (lib/pii.server.ts). The extension keeps the field types, + * so the client is typed as a plain PrismaClient. */ -export function createPrisma(url = process.env.DATABASE_URL) { +export function createPrisma(url = process.env.DATABASE_URL): PrismaClient { if (!url) throw new Error("DATABASE_URL is not set"); - return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }); + return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }).$extends(piiExtension) as unknown as PrismaClient; } function instance(): PrismaClient { diff --git a/app/lib/__tests__/pii.server.test.ts b/app/lib/__tests__/pii.server.test.ts new file mode 100644 index 0000000..64ac157 --- /dev/null +++ b/app/lib/__tests__/pii.server.test.ts @@ -0,0 +1,91 @@ +/** + * Customer personal data is encrypted at rest. + * Crypto unit tests always run; the database tests need TEST_DATABASE_URL (migrations applied). + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { PrismaClient } from "@prisma/client"; +import { createPrisma } from "../../db.server"; +import { blindIndex, seal, unseal } from "../../services/crypto.server"; + +describe("crypto helpers", () => { + it("seals and unseals, and reads legacy plain text unchanged", () => { + const s = seal("Priya Raman")!; + expect(s.startsWith("enc1:")).toBe(true); + expect(s).not.toContain("Priya"); + expect(unseal(s)).toBe("Priya Raman"); + expect(unseal("plain old value")).toBe("plain old value"); + expect(seal(null)).toBeNull(); + expect(seal(s)).toBe(s); // never double-encrypts + }); + it("blind indexes normalise and differ by kind", () => { + expect(blindIndex(" Priya@Example.com ", "email")).toBe(blindIndex("priya@example.com", "email")); + expect(blindIndex("+1 (519) 555-0101", "phone")).toBe(blindIndex("+15195550101", "phone")); + expect(blindIndex("x", "email")).not.toBe(blindIndex("x", "phone")); + expect(blindIndex("", "email")).toBeNull(); + }); +}); + +const url = process.env.TEST_DATABASE_URL; +const d = url ? describe : describe.skip; + +d("PII encryption at rest (Postgres)", () => { + let db: PrismaClient; + let shopId: string; + const base = { method: "delivery", status: "confirmed", timezone: "America/Toronto", configVersion: 1, snapshot: {} }; + const pii = { + customerName: "Priya Raman", + customerEmail: "Priya@Example.com", + customerPhone: "+1 519 555 0101", + address: { line1: "12 Guelph St", city: "Kitchener", postal: "N2H 5Z6" }, + }; + + beforeAll(() => { + db = createPrisma(url); + }); + afterAll(async () => db.$disconnect()); + beforeEach(async () => { + await db.shop.deleteMany({ where: { domain: "pii.myshopify.com" } }); + shopId = (await db.shop.create({ data: { domain: "pii.myshopify.com", settings: {} } })).id; + }); + + it("stores only ciphertext and reads back plain values", async () => { + const b = await db.booking.create({ data: { shopId, orderId: "o1", ...base, ...pii } }); + expect(b.customerName).toBe("Priya Raman"); + expect(b.address).toEqual(pii.address); + const [raw] = await db.$queryRaw<{ customerName: string; customerEmail: string; customerPhone: string; address: unknown }[]>` + SELECT "customerName", "customerEmail", "customerPhone", "address" FROM "Booking" WHERE id = ${b.id}`; + const dump = JSON.stringify(raw); + for (const secret of ["Priya", "Example.com", "555", "Guelph", "N2H"]) expect(dump).not.toContain(secret); + expect(raw.customerEmail.startsWith("enc1:")).toBe(true); + const again = await db.booking.findUniqueOrThrow({ where: { id: b.id } }); + expect([again.customerName, again.customerEmail, again.customerPhone]).toEqual([pii.customerName, pii.customerEmail, pii.customerPhone]); + }); + + it("decrypts nested reads (reminder job → booking)", async () => { + const b = await db.booking.create({ data: { shopId, orderId: "o2", ...base, ...pii } }); + await db.reminderJob.create({ data: { shopId, bookingId: b.id, policyId: "p", channel: "email", sendAt: new Date(), body: "hi" } }); + const j = await db.reminderJob.findFirstOrThrow({ where: { bookingId: b.id }, include: { booking: true } }); + expect(j.booking.customerEmail).toBe(pii.customerEmail); + }); + + it("finds by email/phone hash and redacts everything", async () => { + await db.booking.create({ data: { shopId, orderId: "o3", ...base, ...pii } }); + const hash = blindIndex("priya@example.com", "email")!; + expect(await db.booking.count({ where: { shopId, customerEmailHash: hash } })).toBe(1); + expect(await db.booking.count({ where: { shopId, customerPhoneHash: blindIndex("+15195550101", "phone")! } })).toBe(1); + await db.booking.updateMany({ + where: { shopId, customerEmailHash: hash }, + data: { customerName: null, customerEmail: null, customerPhone: null, customerEmailHash: null, customerPhoneHash: null }, + }); + const r = await db.booking.findFirstOrThrow({ where: { shopId, orderId: "o3" } }); + expect([r.customerName, r.customerEmail, r.customerPhone, r.customerEmailHash]).toEqual([null, null, null, null]); + }); + + it("encrypts waitlist contacts and matches them by hash", async () => { + await db.waitlistEntry.create({ data: { shopId, locationId: "kit", method: "delivery", date: "2026-11-07", slot: "*", email: "a@b.co", name: "Anu" } }); + const [raw] = await db.$queryRaw<{ email: string; name: string }[]>`SELECT email, name FROM "WaitlistEntry" WHERE "shopId" = ${shopId}`; + expect(raw.email).not.toContain("a@b.co"); + const w = await db.waitlistEntry.findFirstOrThrow({ where: { shopId, emailHash: blindIndex("A@B.co", "email")! } }); + expect([w.email, w.name]).toEqual(["a@b.co", "Anu"]); + }); +}); diff --git a/app/lib/bookings-query.server.ts b/app/lib/bookings-query.server.ts index ad6998a..19c47a2 100644 --- a/app/lib/bookings-query.server.ts +++ b/app/lib/bookings-query.server.ts @@ -1,4 +1,5 @@ import type { Prisma } from "@prisma/client"; +import { blindIndex } from "../services/crypto.server"; /** Booking list filters shared by the list page and the CSV export. */ export function bookingWhere(shopId: string, sp: URLSearchParams, today: string): Prisma.BookingWhereInput { @@ -15,6 +16,12 @@ export function bookingWhere(shopId: string, sp: URLSearchParams, today: string) const to = sp.get("to"); if (from || to) where.date = { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) }; const q = sp.get("q")?.trim(); - if (q) where.AND = [{ OR: [{ orderName: { contains: q, mode: "insensitive" } }, { customerName: { contains: q, mode: "insensitive" } }, { customerEmail: { contains: q, mode: "insensitive" } }] }]; + // Customer details are encrypted at rest: match order numbers by text, emails and phones exactly (by keyed hash). + if (q) { + const or: Prisma.BookingWhereInput[] = [{ orderName: { contains: q, mode: "insensitive" } }]; + if (q.includes("@")) or.push({ customerEmailHash: blindIndex(q, "email") }); + else if (/^[\d\s()+.-]{7,}$/.test(q)) or.push({ customerPhoneHash: blindIndex(q, "phone") }); + where.AND = [{ OR: or }]; + } return where; } diff --git a/app/lib/pii.server.ts b/app/lib/pii.server.ts new file mode 100644 index 0000000..d60994a --- /dev/null +++ b/app/lib/pii.server.ts @@ -0,0 +1,101 @@ +/** + * 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) }, + }, + }, +}); diff --git a/app/routes/app.bookings._index.tsx b/app/routes/app.bookings._index.tsx index 3ffbafc..27248ed 100644 --- a/app/routes/app.bookings._index.tsx +++ b/app/routes/app.bookings._index.tsx @@ -64,7 +64,7 @@ export default function Bookings() { - + Apply diff --git a/app/routes/privacy.tsx b/app/routes/privacy.tsx index 1a989a9..a450ca2 100644 --- a/app/routes/privacy.tsx +++ b/app/routes/privacy.tsx @@ -45,8 +45,10 @@ export default function Privacy() {

Security

- Data is encrypted in transit (HTTPS). Messaging credentials you enter are encrypted at rest. Access to our systems is limited - to authorised staff. Our data processing terms are in the terms of service. + Data is encrypted in transit (HTTPS). Customers' names, emails, phone numbers and addresses, and the messaging credentials + you enter, are encrypted at rest (AES-256-GCM); lookups use keyed hashes, never the plain values. Database backups are + encrypted. Access to our systems is limited to authorised staff. Our data processing terms are in + the terms of service.

Retention and deletion

diff --git a/app/routes/terms.tsx b/app/routes/terms.tsx index daefe46..71c9888 100644 --- a/app/routes/terms.tsx +++ b/app/routes/terms.tsx @@ -35,7 +35,7 @@ export default function Terms() {
  • process customer data (name, email, phone, delivery address, order and booking details) only to provide the app's features and on your instructions given through the app;
  • do not sell or share personal data, and do not use it for advertising, profiling, automated decisions or training AI models;
  • -
  • encrypt data in transit (HTTPS) and encrypt messaging credentials at rest;
  • +
  • encrypt data in transit (HTTPS), encrypt customer contact details and messaging credentials at rest (AES-256-GCM, keyed hashing), and encrypt backups;
  • limit access to our systems to authorised staff bound by confidentiality;
  • use these sub-processors: our hosting provider, Resend (email) and Google Maps (delivery distances); Twilio and Meta only when you connect your own accounts. We will tell you before adding others;
  • notify you without undue delay, and within 72 hours, after becoming aware of a breach affecting your data;
  • diff --git a/app/routes/webhooks.compliance.tsx b/app/routes/webhooks.compliance.tsx index 9590010..bc3040f 100644 --- a/app/routes/webhooks.compliance.tsx +++ b/app/routes/webhooks.compliance.tsx @@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client"; import type { ActionFunctionArgs } from "react-router"; import prisma from "../db.server"; import { authenticate } from "../shopify.server"; +import { blindIndex } from "../services/crypto.server"; /** * Mandatory privacy webhooks. @@ -13,24 +14,27 @@ export const action = async ({ request }: ActionFunctionArgs) => { const { shop, topic, payload } = await authenticate.webhook(request); const p = payload as { customer?: { email?: string; phone?: string }; orders_to_redact?: number[] }; const s = await prisma.shop.findUnique({ where: { domain: shop } }); + // Contact details are encrypted at rest; match them by keyed hash. + const emailHash = blindIndex(p.customer?.email, "email"); + const phoneHash = blindIndex(p.customer?.phone, "phone"); if (!s) return new Response(); if (topic === "CUSTOMERS_DATA_REQUEST") { - const n = await prisma.booking.count({ where: { shopId: s.id, customerEmail: p.customer?.email ?? "__none__" } }); + const n = emailHash ? await prisma.booking.count({ where: { shopId: s.id, customerEmailHash: emailHash } }) : 0; console.info(`data_request for ${shop}: ${n} bookings hold this customer's contact details`); } if (topic === "CUSTOMERS_REDACT") { const orderIds = (p.orders_to_redact ?? []).map((id) => `gid://shopify/Order/${id}`); const where = { shopId: s.id, - OR: [{ orderId: { in: orderIds } }, ...(p.customer?.email ? [{ customerEmail: p.customer.email }] : [])], + OR: [{ orderId: { in: orderIds } }, ...(emailHash ? [{ customerEmailHash: emailHash }] : [])], }; await prisma.booking.updateMany({ where, - data: { customerName: null, customerEmail: null, customerPhone: null, address: Prisma.DbNull }, + data: { customerName: null, customerEmail: null, customerPhone: null, address: Prisma.DbNull, customerEmailHash: null, customerPhoneHash: null }, }); - if (p.customer?.email || p.customer?.phone) - await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [{ email: p.customer?.email ?? "__" }, { phone: p.customer?.phone ?? "__" }] } }); + if (emailHash || phoneHash) + await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [...(emailHash ? [{ emailHash }] : []), ...(phoneHash ? [{ phoneHash }] : [])] } }); } if (topic === "SHOP_REDACT") { await prisma.shop.delete({ where: { id: s.id } }); diff --git a/app/services/crypto.server.ts b/app/services/crypto.server.ts index e339338..537de6a 100644 --- a/app/services/crypto.server.ts +++ b/app/services/crypto.server.ts @@ -1,22 +1,53 @@ -/** AES-256-GCM for merchant messaging credentials. APP_ENCRYPTION_KEY = 32 random bytes, base64. */ -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +/** + * AES-256-GCM encryption at rest (messaging credentials and customer contact details) and keyed + * hashes ("blind indexes") for exact-match lookups on encrypted values. + * APP_ENCRYPTION_KEY = 32 random bytes, base64 (`openssl rand -base64 32`). + */ +import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto"; -function key(): Buffer { - const k = Buffer.from(process.env.APP_ENCRYPTION_KEY ?? "", "base64"); - if (k.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)"); - return k; +let cached: { raw: string; enc: Buffer; idx: Buffer } | null = null; +function keys() { + const raw = process.env.APP_ENCRYPTION_KEY ?? ""; + if (cached?.raw === raw) return cached; + const enc = Buffer.from(raw, "base64"); + if (enc.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)"); + // A separate key for hashing, derived so one secret covers both uses. + const idx = createHmac("sha256", enc).update("datewise:blind-index:v1").digest(); + cached = { raw, enc, idx }; + return cached; } export function encrypt(plain: string): string { const iv = randomBytes(12); - const c = createCipheriv("aes-256-gcm", key(), iv); + const c = createCipheriv("aes-256-gcm", keys().enc, iv); const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]); return [iv, c.getAuthTag(), enc].map((b) => b.toString("base64url")).join("."); } export function decrypt(blob: string): string { const [iv, tag, enc] = blob.split(".").map((p) => Buffer.from(p, "base64url")); - const d = createDecipheriv("aes-256-gcm", key(), iv); + const d = createDecipheriv("aes-256-gcm", keys().enc, iv); d.setAuthTag(tag); return Buffer.concat([d.update(enc), d.final()]).toString("utf8"); } + +/** Marks sealed column values so rows written before encryption (plain text) still read correctly. */ +const PREFIX = "enc1:"; + +export function seal(plain: string | null | undefined): string | null | undefined { + if (plain == null || plain === "" || plain.startsWith(PREFIX)) return plain; + return PREFIX + encrypt(plain); +} + +export function unseal(value: string | null | undefined): string | null { + if (value == null) return null; + return value.startsWith(PREFIX) ? decrypt(value.slice(PREFIX.length)) : value; +} + +/** Keyed hash of a normalised email or phone, for exact-match queries. Never reversible. */ +export function blindIndex(value: string | null | undefined, kind: "email" | "phone"): string | null { + if (!value) return null; + const norm = kind === "email" ? value.trim().toLowerCase() : value.replace(/[^\d+]/g, ""); + if (!norm) return null; + return createHmac("sha256", keys().idx).update(`${kind}:${norm}`).digest("hex"); +} diff --git a/app/services/storefront.server.ts b/app/services/storefront.server.ts index 677c50e..60626f2 100644 --- a/app/services/storefront.server.ts +++ b/app/services/storefront.server.ts @@ -19,6 +19,7 @@ import { resolveAddress } from "./geocode.server"; import { features } from "./plans"; import { ReservationService } from "./reservations.server"; import { SELECTION_ATTR } from "./settings"; +import { blindIndex } from "./crypto.server"; export const reservations = new ReservationService(prisma); @@ -260,7 +261,10 @@ export async function joinWaitlist(domain: string, raw: unknown) { const cfg = await loadShopConfig(domain); if (!features(cfg.shop.plan).waitlist) return { ok: false, message: "Waitlist isn't available" }; const dup = await prisma.waitlistEntry.findFirst({ - where: { shopId: cfg.shop.id, locationId: w.locationId, method: w.method, date: w.date, OR: [{ email: w.email ?? "__" }, { phone: w.phone ?? "__" }] }, + where: { + shopId: cfg.shop.id, locationId: w.locationId, method: w.method, date: w.date, + OR: [{ emailHash: blindIndex(w.email, "email") ?? "__" }, { phoneHash: blindIndex(w.phone, "phone") ?? "__" }], + }, }); if (!dup) await prisma.waitlistEntry.create({ data: { shopId: cfg.shop.id, ...w } }); return { ok: true }; diff --git a/prisma/migrations/0002_pii_encryption/migration.sql b/prisma/migrations/0002_pii_encryption/migration.sql new file mode 100644 index 0000000..b3d4f4b --- /dev/null +++ b/prisma/migrations/0002_pii_encryption/migration.sql @@ -0,0 +1,8 @@ +-- Customer contact details are now encrypted at rest by the app (app/lib/pii.server.ts). +-- Keyed hashes allow exact-match lookups (privacy webhooks, booking search) without decrypting. +ALTER TABLE "Booking" ADD COLUMN "customerEmailHash" TEXT; +ALTER TABLE "Booking" ADD COLUMN "customerPhoneHash" TEXT; +CREATE INDEX "Booking_shopId_customerEmailHash_idx" ON "Booking"("shopId", "customerEmailHash"); + +ALTER TABLE "WaitlistEntry" ADD COLUMN "emailHash" TEXT; +ALTER TABLE "WaitlistEntry" ADD COLUMN "phoneHash" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43823a5..f86adb5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,47 +12,47 @@ datasource db { // Required by @shopify/shopify-app-session-storage-prisma model Session { - id String @id - shop String - state String - isOnline Boolean @default(false) - scope String? - expires DateTime? - accessToken String - userId BigInt? - firstName String? - lastName String? - email String? - accountOwner Boolean @default(false) - locale String? - collaborator Boolean? @default(false) - emailVerified Boolean? @default(false) - refreshToken String? - refreshTokenExpires DateTime? + id String @id + shop String + state String + isOnline Boolean @default(false) + scope String? + expires DateTime? + accessToken String + userId BigInt? + firstName String? + lastName String? + email String? + accountOwner Boolean @default(false) + locale String? + collaborator Boolean? @default(false) + emailVerified Boolean? @default(false) + refreshToken String? + refreshTokenExpires DateTime? } model Shop { - id String @id @default(cuid()) - domain String @unique - currency String @default("CAD") - timezone String @default("America/Toronto") + id String @id @default(cuid()) + domain String @unique + currency String @default("CAD") + timezone String @default("America/Toronto") /// ShopSettings JSON (holds, reschedule policy, reminders, mixed-cart policy, widget text) - settings Json + settings Json /// Incremented on every config change; stored on bookings as the rule version used. - configVersion Int @default(1) - plan String @default("free") - feeProductId String? - installedAt DateTime @default(now()) - uninstalledAt DateTime? - locations Location[] - rules ProductRule[] - peakDays PeakDay[] - bookings Booking[] - holds Hold[] - counters CapacityCounter[] - waitlist WaitlistEntry[] - reminderJobs ReminderJob[] - events BookingEvent[] + configVersion Int @default(1) + plan String @default("free") + feeProductId String? + installedAt DateTime @default(now()) + uninstalledAt DateTime? + locations Location[] + rules ProductRule[] + peakDays PeakDay[] + bookings Booking[] + holds Hold[] + counters CapacityCounter[] + waitlist WaitlistEntry[] + reminderJobs ReminderJob[] + events BookingEvent[] } model Location { @@ -147,40 +147,44 @@ model Hold { } model Booking { - id String @id @default(cuid()) - shopId String - shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) - orderId String? - orderName String? - status String // confirmed | needs_review | cancelled | fulfilled - reviewReason String? - method String - locationId String? - date String? // local YYYY-MM-DD - slotStart String? // local HH:MM - slotEnd String? - timezone String - startUtc DateTime? - endUtc DateTime? - customerName String? - customerEmail String? - customerPhone String? - address Json? - items Json? - fee Int @default(0) - peakDayId String? - rescheduleCount Int @default(0) - configVersion Int + id String @id @default(cuid()) + shopId String + shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) + orderId String? + orderName String? + status String // confirmed | needs_review | cancelled | fulfilled + reviewReason String? + method String + locationId String? + date String? // local YYYY-MM-DD + slotStart String? // local HH:MM + slotEnd String? + timezone String + startUtc DateTime? + endUtc DateTime? + /// Encrypted at rest (app/lib/pii.server.ts). Query email/phone through the *Hash columns. + customerName String? + customerEmail String? + customerPhone String? + address Json? + customerEmailHash String? + customerPhoneHash String? + items Json? + fee Int @default(0) + peakDayId String? + rescheduleCount Int @default(0) + configVersion Int /// Snapshot of what the customer agreed to (method, location, slot, fee, rules matched) - snapshot Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - events BookingEvent[] - reminderJobs ReminderJob[] + snapshot Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + events BookingEvent[] + reminderJobs ReminderJob[] @@unique([shopId, orderId]) @@index([shopId, date]) @@index([shopId, status]) + @@index([shopId, customerEmailHash]) } model BookingEvent { @@ -189,8 +193,8 @@ model BookingEvent { shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade) bookingId String booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) - actor String // system | merchant: | customer - action String // confirmed | flagged | rescheduled | cancelled | resolved + actor String // system | merchant: | customer + action String // confirmed | flagged | rescheduled | cancelled | resolved before Json? after Json? at DateTime @default(now()) @@ -205,10 +209,13 @@ model WaitlistEntry { locationId String method String date String - slot String // "*" = any slot that day + slot String // "*" = any slot that day + /// Encrypted at rest (app/lib/pii.server.ts). Query through emailHash / phoneHash. email String? phone String? name String? + emailHash String? + phoneHash String? createdAt DateTime @default(now()) offeredAt DateTime? /// Hold token reserved for this person when a place is offered. diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0326109 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + // Test-only key (the bytes spell "datewise-test-key-not-a-secret!!"). Production reads APP_ENCRYPTION_KEY from .env. + env: { APP_ENCRYPTION_KEY: "ZGF0ZXdpc2UtdGVzdC1rZXktbm90LWEtc2VjcmV0ISE=" }, + }, +});