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:
parent
1e19b773cd
commit
1d2d943d54
40
SECURITY.md
Normal file
40
SECURITY.md
Normal 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.
|
||||||
@ -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 {
|
||||||
|
|||||||
91
app/lib/__tests__/pii.server.test.ts
Normal file
91
app/lib/__tests__/pii.server.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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
101
app/lib/pii.server.ts
Normal 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) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 } });
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
8
prisma/migrations/0002_pii_encryption/migration.sql
Normal file
8
prisma/migrations/0002_pii_encryption/migration.sql
Normal 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;
|
||||||
@ -12,47 +12,47 @@ datasource db {
|
|||||||
|
|
||||||
// Required by @shopify/shopify-app-session-storage-prisma
|
// Required by @shopify/shopify-app-session-storage-prisma
|
||||||
model Session {
|
model Session {
|
||||||
id String @id
|
id String @id
|
||||||
shop String
|
shop String
|
||||||
state String
|
state String
|
||||||
isOnline Boolean @default(false)
|
isOnline Boolean @default(false)
|
||||||
scope String?
|
scope String?
|
||||||
expires DateTime?
|
expires DateTime?
|
||||||
accessToken String
|
accessToken String
|
||||||
userId BigInt?
|
userId BigInt?
|
||||||
firstName String?
|
firstName String?
|
||||||
lastName String?
|
lastName String?
|
||||||
email String?
|
email String?
|
||||||
accountOwner Boolean @default(false)
|
accountOwner Boolean @default(false)
|
||||||
locale String?
|
locale String?
|
||||||
collaborator Boolean? @default(false)
|
collaborator Boolean? @default(false)
|
||||||
emailVerified Boolean? @default(false)
|
emailVerified Boolean? @default(false)
|
||||||
refreshToken String?
|
refreshToken String?
|
||||||
refreshTokenExpires DateTime?
|
refreshTokenExpires DateTime?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Shop {
|
model Shop {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
domain String @unique
|
domain String @unique
|
||||||
currency String @default("CAD")
|
currency String @default("CAD")
|
||||||
timezone String @default("America/Toronto")
|
timezone String @default("America/Toronto")
|
||||||
/// ShopSettings JSON (holds, reschedule policy, reminders, mixed-cart policy, widget text)
|
/// 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.
|
/// Incremented on every config change; stored on bookings as the rule version used.
|
||||||
configVersion Int @default(1)
|
configVersion Int @default(1)
|
||||||
plan String @default("free")
|
plan String @default("free")
|
||||||
feeProductId String?
|
feeProductId String?
|
||||||
installedAt DateTime @default(now())
|
installedAt DateTime @default(now())
|
||||||
uninstalledAt DateTime?
|
uninstalledAt DateTime?
|
||||||
locations Location[]
|
locations Location[]
|
||||||
rules ProductRule[]
|
rules ProductRule[]
|
||||||
peakDays PeakDay[]
|
peakDays PeakDay[]
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
holds Hold[]
|
holds Hold[]
|
||||||
counters CapacityCounter[]
|
counters CapacityCounter[]
|
||||||
waitlist WaitlistEntry[]
|
waitlist WaitlistEntry[]
|
||||||
reminderJobs ReminderJob[]
|
reminderJobs ReminderJob[]
|
||||||
events BookingEvent[]
|
events BookingEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Location {
|
model Location {
|
||||||
@ -147,40 +147,44 @@ model Hold {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Booking {
|
model Booking {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
shopId String
|
shopId String
|
||||||
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
|
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
|
||||||
orderId String?
|
orderId String?
|
||||||
orderName String?
|
orderName String?
|
||||||
status String // confirmed | needs_review | cancelled | fulfilled
|
status String // confirmed | needs_review | cancelled | fulfilled
|
||||||
reviewReason String?
|
reviewReason String?
|
||||||
method String
|
method String
|
||||||
locationId String?
|
locationId String?
|
||||||
date String? // local YYYY-MM-DD
|
date String? // local YYYY-MM-DD
|
||||||
slotStart String? // local HH:MM
|
slotStart String? // local HH:MM
|
||||||
slotEnd String?
|
slotEnd String?
|
||||||
timezone String
|
timezone String
|
||||||
startUtc DateTime?
|
startUtc DateTime?
|
||||||
endUtc DateTime?
|
endUtc DateTime?
|
||||||
customerName String?
|
/// Encrypted at rest (app/lib/pii.server.ts). Query email/phone through the *Hash columns.
|
||||||
customerEmail String?
|
customerName String?
|
||||||
customerPhone String?
|
customerEmail String?
|
||||||
address Json?
|
customerPhone String?
|
||||||
items Json?
|
address Json?
|
||||||
fee Int @default(0)
|
customerEmailHash String?
|
||||||
peakDayId String?
|
customerPhoneHash String?
|
||||||
rescheduleCount Int @default(0)
|
items Json?
|
||||||
configVersion Int
|
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 of what the customer agreed to (method, location, slot, fee, rules matched)
|
||||||
snapshot Json
|
snapshot Json
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
events BookingEvent[]
|
events BookingEvent[]
|
||||||
reminderJobs ReminderJob[]
|
reminderJobs ReminderJob[]
|
||||||
|
|
||||||
@@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 {
|
||||||
@ -189,8 +193,8 @@ model BookingEvent {
|
|||||||
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
|
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
|
||||||
bookingId String
|
bookingId String
|
||||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||||
actor String // system | merchant:<staff id> | customer
|
actor String // system | merchant:<staff id> | customer
|
||||||
action String // confirmed | flagged | rescheduled | cancelled | resolved
|
action String // confirmed | flagged | rescheduled | cancelled | resolved
|
||||||
before Json?
|
before Json?
|
||||||
after Json?
|
after Json?
|
||||||
at DateTime @default(now())
|
at DateTime @default(now())
|
||||||
@ -205,10 +209,13 @@ model WaitlistEntry {
|
|||||||
locationId String
|
locationId String
|
||||||
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
10
vitest.config.ts
Normal 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=" },
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user