Files
pcrt-legal-return/app/app/routes/webhooks.customers.redact.tsx
tommaso ad9f76b6be R4 hardening (A9): retry ricevuta + webhook GDPR + rate-limit prune + fix PII/UX
Da review avversariale indipendente:
- Retry invio email (trySend, 3 tentativi backoff) per ricevuta cliente e notifica merchant.
- Webhook GDPR implementati (erano stub) con idempotenza (skip se gia' processed):
  * customers/redact: pseudonimizza PII (nome/email/dichiarazione) nelle
    WithdrawalRequest del cliente, mantiene il record legale (ordine+timestamp)
    come prova art. 54-bis (base art. 17(3) GDPR).
  * shop/redact: purge completa dei dati shop (Settings/Exclusion/Withdrawal/
    Session/AuditLog + WebhookEvent stale).
  * customers/data_request: registra la richiesta + n. record (merchant li recupera
    dalla dashboard Recessi).
- Rate-limit: pruning periodico del bucket in-memory (fix memory leak).
- Ricevuta fallita: messaggio di successo onesto (successMessage riceve receipt.ok)
  invece del falso 'ti abbiamo inviato la ricevuta'.
- PII: rimossa dai detail audit persistiti degli errori SMTP (ricevuta/notifica).

Non modificati (verificati): off-by-1 finestra = permissivo, non blocca a torto;
'doppio escape' = falso allarme (template e valori escapati una volta ciascuno).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
2026-07-07 17:07:06 +02:00

65 lines
2.1 KiB
TypeScript

import type { ActionFunctionArgs } from "@remix-run/node";
import { createHash } from "node:crypto";
import { authenticate } from "../shopify.server";
import db from "../db.server";
/**
* GDPR mandatory compliance webhook: customers/redact.
* Shopify sends this (typically 10 days after an order is cancelled/deleted, or
* on merchant request) to require deletion of a customer's personal data.
* HMAC is verified by authenticate.webhook(request); an invalid request throws.
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, webhookId } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
const payloadHash = createHash("sha256")
.update(JSON.stringify(payload ?? {}))
.digest("hex");
// Idempotency: se gia' lavorato, esci.
const dedupeKey = webhookId ?? `${topic}:${payloadHash}`;
const existing = await db.webhookEvent.findUnique({
where: { webhookId: dedupeKey },
});
if (existing?.processed) return new Response();
await db.webhookEvent.upsert({
where: { webhookId: dedupeKey },
create: { shop, topic, webhookId: dedupeKey, processed: false },
update: {},
});
// Pseudonimizza la PII del cliente nelle richieste di recesso, mantenendo il
// record legale (ordine, timestamp) come prova ex art. 54-bis. Base di
// conservazione: obbligo legale / difesa in giudizio (art. 17(3) GDPR).
const email = ((payload as { customer?: { email?: unknown } } | null)?.customer
?.email ?? null) as string | null;
let redacted = 0;
if (typeof email === "string" && email) {
const res = await db.withdrawalRequest.updateMany({
where: { shop, email },
data: {
customerName: "[redatto]",
email: "[redatto]",
statementText: "[redatto]",
},
});
redacted = res.count;
}
await db.auditLog.create({
data: {
shop,
event: `gdpr.${topic}`,
payloadHash,
detail: `customers/redact: ${redacted} record pseudonimizzati`,
},
});
await db.webhookEvent.update({
where: { webhookId: dedupeKey },
data: { processed: true },
});
return new Response();
};