# Users can download a machine-readable export of their personal data

- **Pattern:** `ab-002618` (`project-snapshot.legal.data-export-endpoint-exists`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-23
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002618
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002618)

## Why it matters

GDPR Art. 20 gives every EU user a statutory right to receive a structured, machine-readable copy of the personal data you hold about them, and CCPA §1798.110 gives California users a parallel right-to-know. Both have a one-month (GDPR Art. 12) response clock that starts the moment a request lands in a support inbox. Without a working export endpoint the only way to satisfy a portability request is a panic-mode manual database dump — which regulators treat as a risk factor in its own right, because ad-hoc dumps leak data they should not include. The GDPR fine ceiling for Art. 20 non-compliance is 4% of global turnover or €20M; CCPA AG enforcement has reached $7,500 per affected consumer. AI coding tools reliably build login and profile-edit flows, but rarely scaffold a `/api/me/export` route, because training corpora skew heavily toward CRUD-happy-path code and away from regulator-facing plumbing. The quiet failure mode is a live site that looks compliant until the first SAR lands and nobody knows where to point it.

## Severity rationale

High because missing export directly blocks Art. 20 / §1798.110 compliance on a one-month regulator clock, scales liability per-user, and cannot be handwaved as "best effort" — the obligation is binary.

## Remediation

Add an authenticated GET route at `app/api/me/export/route.ts` that fetches every row tied to the requesting user across your main tables, serializes to JSON, and returns it with a `Content-Disposition: attachment; filename="my-data.json"` header.

```ts
// app/api/me/export/route.ts
export async function GET() {
  const session = await getServerSession()
  if (!session?.user?.id) return new Response('Unauthorized', { status: 401 })
  const data = await db.user.findUnique({
    where: { id: session.user.id },
    include: { orders: true, activityLog: true, preferences: true },
  })
  return new Response(JSON.stringify(data, null, 2), {
    headers: { 'Content-Type': 'application/json', 'Content-Disposition': 'attachment; filename="my-data.json"' },
  })
}
```

Rate-limit to one export per user per 24 hours. Wire a "Download my data" button in `app/settings/page.tsx`. Deeper coverage of the portability workflow (file uploads, third-party processor data, completeness checks) lives in the `gdpr-readiness` Pro audit.

## Detection

- **ID:** `data-export-endpoint-exists`
- **Severity:** `high`
- **What to look for:** First confirm user accounts exist (same evidence as account deletion: auth library in `package.json`, auth routes, `User` model). If none, skip. Otherwise enumerate export surfaces. Look for a route at one of `app/api/user/export`, `app/api/me/export`, `app/api/account/export`, `app/api/privacy/data`, `app/api/export`, `app/me/download`, `app/settings/export`, `pages/api/user/export`, or a server action whose name matches `/^(export|download|request)(UserData|Account|MyData|DataExport)$/i`. Inspect the handler body: it must actually build a payload from user data and return it (JSON, CSV, ZIP), typically via `db.user.findUnique` / `supabase.from('users').select` followed by `new Response(JSON.stringify(...))` with a `Content-Disposition: attachment` header or equivalent. Reject stubs: a route returning `{ message: 'Coming soon' }`, a page that only links to `mailto:privacy@` with no server handler, or a response body that is literally empty.
- **Pass criteria:** User accounts exist AND an authenticated export endpoint is present AND the handler body constructs and returns a real data payload (JSON, CSV, or ZIP) rather than a placeholder message or a redirect to a contact form.
- **Fail criteria:** User accounts exist AND either (a) no export endpoint is found, or (b) the endpoint exists but returns a stub ("Coming soon", empty object, mailto redirect).
- **Skip (N/A) when:** No user accounts exist. Quote: `"No auth library, auth routes, or user model detected — project has no user accounts."`
- **Do NOT pass when:** A file exists at an export path but the handler body is a stub: returning `{ message: 'Contact support' }`, redirecting to an email link, throwing 501 Not Implemented, or commented-out TODO. The endpoint must actually emit data.
- **Before evaluating, quote:** Quote the exact file path and 2–4 lines of the handler body that construct the response payload. If no handler exists, quote the auth evidence showing accounts exist: `"<file>: <line referencing auth>"`.
- **Report even on pass:** `"Export handler at <path>; format: <json|csv|zip>; includes: <tables queried>."`
- **Detail on fail:** `"User accounts present (Clerk in package.json, sign-in route at app/sign-in/) but no export route found under app/api/user/, app/api/me/, app/api/account/, or app/api/export/."` or `"Route at app/api/me/export/route.ts exists but handler body only returns new Response('Coming soon', { status: 501 })."`
- **Remediation:** See the fenced `app/api/me/export/route.ts` example above in `remediation_prose`. Rate-limit to one export per user per 24 hours and surface the trigger in `app/settings/page.tsx`. Deeper coverage lives in the `gdpr-readiness` Pro audit.

## External references

- gdpr Art. 20
- ccpa §1798.110

Taxons: project-snapshot, regulatory-conformance

HTML version: https://auditbuffet.com/patterns/ab-002618
