Appearance
GDPR Deletion Hardening โ Design โ
Date: 2026-07-29 Status: Approved (Brent, 2026-07-29) Origin: The 2026-07-29 ghost-store audit found four isActive: true stores whose merchants had uninstalled months earlier. Their app/uninstalled and shop/redact webhooks were never processed, so tokens and per-store data were retained indefinitely. Separately, the audit surfaced that shop/redact deletes only a subset of per-shop collections, and that the customer GDPR webhooks are no-ops even though BuylistOrder now stores customer PII.
Problem โ
GDPR deletion has three gaps:
shop/redactis incomplete. The handler (server/routes/webhooks.js) deletesstores,sync_jobs, and theusers.connectedStoreslink. Five per-shop collections added since then are never deleted:billingcycles,processedorders,BuylistOrder,VariantCreationLog, and per-shopSealedProductdrafts (StoreProductalso carries ashopfield).- A missed webhook means data is retained forever.
shop/redactis the only deletion path. Shopify stops retrying webhooks after ~48h of failures, and nothing reconciles afterwards. - The customer webhooks lie.
customers/redactandcustomers/data_requestrespond "this app stores no customer data," but the public buylist portal persists customer email and name inBuylistOrder.customer.
Design โ
1. Shared deletion service โ server/services/gdprService.js โ
A single deleteAllShopData(shop) used by both the shop/redact handler and the fallback sweep.
- Deletion registry: an explicit list of entries, each
{ model, kind }:deleteMany({ shop }):Store,SyncJob,BillingCycle,ProcessedOrder,BuylistOrder,VariantCreationLog,SealedProduct,StoreProduct- special case:
User.updateMany({ 'connectedStores.shop': shop }, { $pull: { connectedStores: { shop } } })
- Returns per-collection result counts; callers log them.
- Follows the repo
_setDeps()/_resetDeps()DI pattern for tests. - The
shop/redactroute handler shrinks to: resolve shop โdeleteAllShopData(shop)โ log counts โ 200.
Drift guard: a test iterates mongoose.models; every model whose schema has a top-level shop path must appear in the registry or on an explicit EXEMPT list (initially empty). Adding a per-shop model without wiring deletion fails the suite. (Same shape as the CLAUDE.md ยง5.1 parity rule: make omission loud.)
2. Fallback sweep for missed webhooks โ
scheduleDailyGdprSweep()inserver/queues/billingQueue.js, mirroringscheduleDailyBilling: repeatable every 24h, stablejobId: 'daily-gdpr-sweep', job namegdpr-sweep. Registered at worker startup alongside the billing schedule.billingProcessoralready routes byjob.name; add agdpr-sweepbranch. No new queue, processor file, or worker wiring.- Sweep logic: find stores matching
{ isActive: false, uninstalledAt: { $lte: now โ 30 days } }; for each, calldeleteAllShopData(shop), log counts, send a Slack notification vianotificationService. - Grace period: 30 days after
uninstalledAt(decided over 48h/7d) โ protects reinstalling merchants and wrongly-deactivated stores. The four ghost stores deactivated 2026-07-29 sweep ~2026-08-28. - Safety invariants: only
isActive: falsestores qualify, so reinstalled stores with staleuninstalledAt(the token-exchange gap, tracked separately) can never match. Deletion removes the Store doc, so the sweep is idempotent by construction. - Error handling: per-store try/catch โ one failure logs and continues; the job result carries
{ swept, errors, details }.
3. Customer-data webhooks โ
customers/redact: anonymize in place, keep business records (decided over full deletion).- Match:
{ shop, 'customer.email': /^<escaped email>$/i }โ email is stored raw inBuylistOrder, so matching is case-insensitive with a regex-escaped exact pattern. - Update:
customer.email โ 'redacted', unsetcustomer.name, clearclaimTokenHash(the claim token is customer-held credential material). - Lines, payout amounts, statuses, and dates remain โ merchant accounting is untouched.
- Match:
customers/data_request: replace the hardcoded "no data stored" payload with the customer's matching buylist orders: order id, status, createdAt, line count, payout summary. Same case-insensitive email match, scoped to the requesting shop.- Update
GDPR_COMPLIANCE.mdto describe the real behavior of all three webhooks.
Testing โ
gdprService.test.js: each registry entry invoked with the correct filter; counts aggregated; User special-case; DI mocks.- Drift test (in the service test file):
mongoose.modelsscan vs registry +EXEMPT. billingProcessor.test.js:gdpr-sweeprouting; 30-day boundary (29 days โ untouched, 31 days โ swept);isActive: truestore with olduninstalledAtโ untouched; one store erroring doesn't stop the next.webhooks.test.js:shop/redactdelegates to the service;customers/redactanonymizes only the matching shop+email (different-case email matches; other shop's orders untouched);customers/data_requestpayload shape.- Existing quality bars: 70% per-file coverage on touched files (read the table โ the Vitest threshold gate is inoperative), lint clean, no new endpoints so no new Zod schemas.
Delivery โ three PRs โ
gdprService+ completeshop/redact+ drift test.- Sweep job (
billingQueue+billingProcessorbranch) + Slack notification. - Customer webhooks (redact anonymization, data_request payload) +
GDPR_COMPLIANCE.mdupdate.
Out of scope โ
- Token-liveness reconciliation and the token-exchange
installedAt/uninstalledAtfix (separate tasks, already chipped). - Any UI surface.
- Backfill deletion of the four ghost stores ahead of schedule โ the sweep handles them.
