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 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-14 18:56:05 -04:00
parent 1e19b773cd
commit 1d2d943d54
14 changed files with 398 additions and 90 deletions

40
SECURITY.md Normal file
View File

@ -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.

View File

@ -1,5 +1,6 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
import { piiExtension } from "./lib/pii.server";
declare global { declare global {
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
@ -8,11 +9,13 @@ declare global {
/** /**
* Rust-free Prisma client over node-postgres (no native engine binary needed, * 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"); 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 { function instance(): PrismaClient {

View File

@ -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"]);
});
});

View File

@ -1,4 +1,5 @@
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { blindIndex } from "../services/crypto.server";
/** Booking list filters shared by the list page and the CSV export. */ /** Booking list filters shared by the list page and the CSV export. */
export function bookingWhere(shopId: string, sp: URLSearchParams, today: string): Prisma.BookingWhereInput { 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"); const to = sp.get("to");
if (from || to) where.date = { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) }; if (from || to) where.date = { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) };
const q = sp.get("q")?.trim(); 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; return where;
} }

101
app/lib/pii.server.ts Normal file
View File

@ -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<string, unknown>;
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) },
},
},
});

View File

@ -64,7 +64,7 @@ export default function Bookings() {
</s-select> </s-select>
<s-date-field label="From" name="from" value={sp.get("from") ?? ""} /> <s-date-field label="From" name="from" value={sp.get("from") ?? ""} />
<s-date-field label="To" name="to" value={sp.get("to") ?? ""} /> <s-date-field label="To" name="to" value={sp.get("to") ?? ""} />
<s-search-field label="Search" name="q" placeholder="Order, name or email" value={sp.get("q") ?? ""} /> <s-search-field label="Search" name="q" placeholder="Order number, or a full email or phone" value={sp.get("q") ?? ""} />
<s-button type="submit">Apply</s-button> <s-button type="submit">Apply</s-button>
</s-grid> </s-grid>
</Form> </Form>

View File

@ -45,8 +45,10 @@ export default function Privacy() {
<h2>Security</h2> <h2>Security</h2>
<p> <p>
Data is encrypted in transit (HTTPS). Messaging credentials you enter are encrypted at rest. Access to our systems is limited Data is encrypted in transit (HTTPS). Customers' names, emails, phone numbers and addresses, and the messaging credentials
to authorised staff. Our data processing terms are in the <a href="/terms">terms of service</a>. 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 <a href="/terms">terms of service</a>.
</p> </p>
<h2>Retention and deletion</h2> <h2>Retention and deletion</h2>

View File

@ -35,7 +35,7 @@ export default function Terms() {
<ul> <ul>
<li>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;</li> <li>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;</li>
<li>do not sell or share personal data, and do not use it for advertising, profiling, automated decisions or training AI models;</li> <li>do not sell or share personal data, and do not use it for advertising, profiling, automated decisions or training AI models;</li>
<li>encrypt data in transit (HTTPS) and encrypt messaging credentials at rest;</li> <li>encrypt data in transit (HTTPS), encrypt customer contact details and messaging credentials at rest (AES-256-GCM, keyed hashing), and encrypt backups;</li>
<li>limit access to our systems to authorised staff bound by confidentiality;</li> <li>limit access to our systems to authorised staff bound by confidentiality;</li>
<li>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;</li> <li>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;</li>
<li>notify you without undue delay, and within 72 hours, after becoming aware of a breach affecting your data;</li> <li>notify you without undue delay, and within 72 hours, after becoming aware of a breach affecting your data;</li>

View File

@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
import prisma from "../db.server"; import prisma from "../db.server";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import { blindIndex } from "../services/crypto.server";
/** /**
* Mandatory privacy webhooks. * Mandatory privacy webhooks.
@ -13,24 +14,27 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request); const { shop, topic, payload } = await authenticate.webhook(request);
const p = payload as { customer?: { email?: string; phone?: string }; orders_to_redact?: number[] }; const p = payload as { customer?: { email?: string; phone?: string }; orders_to_redact?: number[] };
const s = await prisma.shop.findUnique({ where: { domain: shop } }); 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 (!s) return new Response();
if (topic === "CUSTOMERS_DATA_REQUEST") { 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`); console.info(`data_request for ${shop}: ${n} bookings hold this customer's contact details`);
} }
if (topic === "CUSTOMERS_REDACT") { if (topic === "CUSTOMERS_REDACT") {
const orderIds = (p.orders_to_redact ?? []).map((id) => `gid://shopify/Order/${id}`); const orderIds = (p.orders_to_redact ?? []).map((id) => `gid://shopify/Order/${id}`);
const where = { const where = {
shopId: s.id, 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({ await prisma.booking.updateMany({
where, 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) if (emailHash || phoneHash)
await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [{ email: p.customer?.email ?? "__" }, { phone: p.customer?.phone ?? "__" }] } }); await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [...(emailHash ? [{ emailHash }] : []), ...(phoneHash ? [{ phoneHash }] : [])] } });
} }
if (topic === "SHOP_REDACT") { if (topic === "SHOP_REDACT") {
await prisma.shop.delete({ where: { id: s.id } }); await prisma.shop.delete({ where: { id: s.id } });

View File

@ -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 { let cached: { raw: string; enc: Buffer; idx: Buffer } | null = null;
const k = Buffer.from(process.env.APP_ENCRYPTION_KEY ?? "", "base64"); function keys() {
if (k.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)"); const raw = process.env.APP_ENCRYPTION_KEY ?? "";
return k; 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 { export function encrypt(plain: string): string {
const iv = randomBytes(12); 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()]); const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return [iv, c.getAuthTag(), enc].map((b) => b.toString("base64url")).join("."); return [iv, c.getAuthTag(), enc].map((b) => b.toString("base64url")).join(".");
} }
export function decrypt(blob: string): string { export function decrypt(blob: string): string {
const [iv, tag, enc] = blob.split(".").map((p) => Buffer.from(p, "base64url")); 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); d.setAuthTag(tag);
return Buffer.concat([d.update(enc), d.final()]).toString("utf8"); 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");
}

View File

@ -19,6 +19,7 @@ import { resolveAddress } from "./geocode.server";
import { features } from "./plans"; import { features } from "./plans";
import { ReservationService } from "./reservations.server"; import { ReservationService } from "./reservations.server";
import { SELECTION_ATTR } from "./settings"; import { SELECTION_ATTR } from "./settings";
import { blindIndex } from "./crypto.server";
export const reservations = new ReservationService(prisma); export const reservations = new ReservationService(prisma);
@ -260,7 +261,10 @@ export async function joinWaitlist(domain: string, raw: unknown) {
const cfg = await loadShopConfig(domain); const cfg = await loadShopConfig(domain);
if (!features(cfg.shop.plan).waitlist) return { ok: false, message: "Waitlist isn't available" }; if (!features(cfg.shop.plan).waitlist) return { ok: false, message: "Waitlist isn't available" };
const dup = await prisma.waitlistEntry.findFirst({ 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 } }); if (!dup) await prisma.waitlistEntry.create({ data: { shopId: cfg.shop.id, ...w } });
return { ok: true }; return { ok: true };

View File

@ -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;

View File

@ -162,10 +162,13 @@ model Booking {
timezone String timezone String
startUtc DateTime? startUtc DateTime?
endUtc DateTime? endUtc DateTime?
/// Encrypted at rest (app/lib/pii.server.ts). Query email/phone through the *Hash columns.
customerName String? customerName String?
customerEmail String? customerEmail String?
customerPhone String? customerPhone String?
address Json? address Json?
customerEmailHash String?
customerPhoneHash String?
items Json? items Json?
fee Int @default(0) fee Int @default(0)
peakDayId String? peakDayId String?
@ -181,6 +184,7 @@ model Booking {
@@unique([shopId, orderId]) @@unique([shopId, orderId])
@@index([shopId, date]) @@index([shopId, date])
@@index([shopId, status]) @@index([shopId, status])
@@index([shopId, customerEmailHash])
} }
model BookingEvent { model BookingEvent {
@ -206,9 +210,12 @@ model WaitlistEntry {
method String method String
date 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? email String?
phone String? phone String?
name String? name String?
emailHash String?
phoneHash String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
offeredAt DateTime? offeredAt DateTime?
/// Hold token reserved for this person when a place is offered. /// Hold token reserved for this person when a place is offered.

10
vitest.config.ts Normal file
View File

@ -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=" },
},
});