Skip to content

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:

  1. shop/redact is incomplete. The handler (server/routes/webhooks.js) deletes stores, sync_jobs, and the users.connectedStores link. Five per-shop collections added since then are never deleted: billingcycles, processedorders, BuylistOrder, VariantCreationLog, and per-shop SealedProduct drafts (StoreProduct also carries a shop field).
  2. A missed webhook means data is retained forever. shop/redact is the only deletion path. Shopify stops retrying webhooks after ~48h of failures, and nothing reconciles afterwards.
  3. The customer webhooks lie. customers/redact and customers/data_request respond "this app stores no customer data," but the public buylist portal persists customer email and name in BuylistOrder.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/redact route 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() in server/queues/billingQueue.js, mirroring scheduleDailyBilling: repeatable every 24h, stable jobId: 'daily-gdpr-sweep', job name gdpr-sweep. Registered at worker startup alongside the billing schedule.
  • billingProcessor already routes by job.name; add a gdpr-sweep branch. No new queue, processor file, or worker wiring.
  • Sweep logic: find stores matching { isActive: false, uninstalledAt: { $lte: now โˆ’ 30 days } }; for each, call deleteAllShopData(shop), log counts, send a Slack notification via notificationService.
  • 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: false stores qualify, so reinstalled stores with stale uninstalledAt (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 in BuylistOrder, so matching is case-insensitive with a regex-escaped exact pattern.
    • Update: customer.email โ†’ 'redacted', unset customer.name, clear claimTokenHash (the claim token is customer-held credential material).
    • Lines, payout amounts, statuses, and dates remain โ€” merchant accounting is untouched.
  • 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.md to 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.models scan vs registry + EXEMPT.
  • billingProcessor.test.js: gdpr-sweep routing; 30-day boundary (29 days โ†’ untouched, 31 days โ†’ swept); isActive: true store with old uninstalledAt โ†’ untouched; one store erroring doesn't stop the next.
  • webhooks.test.js: shop/redact delegates to the service; customers/redact anonymizes only the matching shop+email (different-case email matches; other shop's orders untouched); customers/data_request payload 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 โ€‹

  1. gdprService + complete shop/redact + drift test.
  2. Sweep job (billingQueue + billingProcessor branch) + Slack notification.
  3. Customer webhooks (redact anonymization, data_request payload) + GDPR_COMPLIANCE.md update.

Out of scope โ€‹

  • Token-liveness reconciliation and the token-exchange installedAt/uninstalledAt fix (separate tasks, already chipped).
  • Any UI surface.
  • Backfill deletion of the four ghost stores ahead of schedule โ€” the sweep handles them.