# How OpenSEO Handles GDPR Storage Erasure: Complete Technical Implementation

> Discover how OpenSEO implements GDPR storage erasure. Learn about its three-phase workflow for data inventory, external vendor deletion, Cloudflare R2/KV/Durable Objects purging, and PostgreSQL record removal.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-09-06

---

**OpenSEO's GDPR storage erasure process uses a three-phase workflow that inventories user data, deletes records from external vendors (Loops, PostHog, Autumn/Stripe), invokes a HMAC-signed worker endpoint to purge Cloudflare R2/KV/Durable Objects, and finally removes PostgreSQL records in a verified transaction.**

`every-app/open-seo` implements GDPR-compliant data deletion through a coordinated system spanning CLI tooling, shared cryptographic utilities, and a secure internal API endpoint. The implementation ensures complete removal of personal data from database, object storage, and third-party integrations.

## Overview of the GDPR Erasure Architecture

The erasure system consists of three core components that work sequentially to guarantee data removal:

- **Inventory phase**: Builds a complete manifest of user-associated resources without making changes
- **External vendor cleanup**: Deletes records from marketing and analytics platforms
- **Storage erasure**: Wipes Cloudflare Workers infrastructure via authenticated endpoint
- **Database cleanup**: Executes transactional deletion with post-commit verification

All phases are orchestrated by [[`scripts/erase-user-data.ts`](https://github.com/every-app/open-seo/blob/main/scripts/erase-user-data.ts)](https://github.com/every-app/open-seo/blob/main/scripts/erase-user-data.ts), which consumes shared utilities from [[`src/shared/gdpr-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/gdpr-erasure.ts)](https://github.com/every-app/open-seo/blob/main/src/shared/gdpr-erasure.ts) and follows procedures documented in [[`runbooks/gdpr-erasure.md`](https://github.com/every-app/open-seo/blob/main/runbooks/gdpr-erasure.md)](https://github.com/every-app/open-seo/blob/main/runbooks/gdpr-erasure.md).

## Phase 1: Building the Data Inventory

The script begins with `buildInventory()`, a dry-run operation that aggregates all resources linked to a target user. This phase enforces critical safety constraints before any destructive operations occur.

### Inventory Coverage

| Resource Category | Data Source | Validation Rule |
|-------------------|-------------|---------------|
| Organizations & membership | Direct query | **Strict**: Fails if any organization has >1 member |
| Projects | Organization-scoped lookup | Logs all project IDs |
| Active audits & workflows | Time-bounded query (30-day window) | Blocks if workflows are running |
| R2 storage objects | `auditLighthouseResults` table | Collects `r2Key` values |
| Google OAuth connections | `accounts` table with `provider="google"` | GSC/GA4 account mappings |
| Sam sessions, keywords, onboarding | Aggregated counts | Enumeration for final report |

The inventory output (lines 78–99 in the script) produces a JSON structure consumed by both human operators and downstream automation. The single-member organization rule prevents accidental deletion of collaborative workspaces.

## Phase 2: External Vendor Data Deletion

Once inventory is approved, the script executes sequential deletions against integrated third-party services. Each operation includes idempotency handling—duplicate deletion attempts return `already_absent` rather than errors.

### Vendor-Specific Implementations

| Service | Function | Endpoint/Method |
|---------|----------|-----------------|
| **Loops** | `deleteLoopsContact` | DELETE `/contacts/{userId}` and `/contacts/{email}` |
| **PostHog** | `deletePostHogPerson` | DELETE `/persons/{distinct_id}` |
| **Autumn / Stripe** | `deleteAutumnCustomer` | DELETE `/customers/{autumn_customer_id}` |

All functions log structured results. The script throws only on unexpected HTTP status codes (5xx or unhandled 4xx), ensuring that already-purged records don't block the erasure pipeline.

## Phase 3: Cloudflare Workers Storage Erasure

The most complex component targets **ephemeral and distributed storage** managed by Cloudflare Workers: R2 buckets, KV namespaces, and Durable Objects. This requires an authenticated cross-boundary request to the worker runtime.

### The `eraseWorkerStorage` Implementation

Located in [`scripts/erase-user-data.ts`](https://github.com/every-app/open-seo/blob/main/scripts/erase-user-data.ts) (lines 36–66), this function constructs a signed request to:

```

POST https://<BETTER_AUTH_URL>/api/internal/gdpr-erasure/storage

```

The shared utility [[`src/shared/gdpr-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/gdpr-erasure.ts)](https://github.com/every-app/open-seo/blob/main/src/shared/gdpr-erasure.ts) provides:

- `GDPR_STORAGE_ERASURE_PATH`: The fixed route constant (`/api/internal/gdpr-erasure/storage`)
- `signGdprErasureRequest(secret, timestamp, payload)`: HMAC-SHA256 signature generation

### Request Authentication Flow

1. Generate millisecond timestamp: `const timestamp = String(Date.now())`
2. Serialize payload with canonical ordering
3. Compute HMAC: `crypto.createHmac('sha256', secret).update(timestamp + payload).digest('hex')`
4. Transmit via headers `x-gdpr-timestamp` and `x-gdpr-signature`

### Worker-Side Execution

Upon signature validation, the worker endpoint terminates running workflow instances, revokes Google OAuth grants, and purges:

- **R2 objects**: All audit payload and lighthouse result files
- **KV entries**: Progress tracking and cached metadata
- **Durable Objects**: Audit state machines and chat/scratch-pad sessions

The worker returns a structured receipt merged into the final erasure report.

## Phase 4: PostgreSQL Transactional Deletion

The final phase executes `erasePostgres()`, a single database transaction that:

1. Purges invitation and verification records by email
2. Removes GSC/GA4 connection rows and API keys
3. Anonymizes audit attribution (preserving aggregate analytics)
4. Deletes single-member organizations
5. Removes the user row

Post-commit, `verifyPostgres()` (lines 41–60) confirms total row removal through re-query, generating the definitive GDPR erasure receipt.

## CLI Usage and Code Examples

### Standard Erasure Workflow

```bash

# Inventory-only dry run (recommended first step)

pnpm gdpr:erase-user --email person@example.com

# Full execution with confirmation safeguards

pnpm gdpr:erase-user \
  --email person@example.com \
  --execute \
  --confirm person@example.com \
  --confirm-database-host us-east-3.pg.psdb.cloud

```

### Direct Worker Endpoint Integration

For external services needing synchronous storage cleanup:

```typescript
import { signGdprErasureRequest } from "./src/shared/gdpr-erasure";

const payload = {
  userId: "user-uuid",
  email: "person@example.com",
  organizationIds: ["org-1"],
  projectIds: ["proj-1"],
  samSessionIds: [],
  auditIds: [],
  activeAuditWorkflowIds: [],
  activeRankWorkflowIds: [],
  r2Keys: ["dataforseo-cache/abc123"],
  googleAccounts: [{ providerId: "gsc", accountId: "12345" }],
};

const timestamp = String(Date.now());
const signature = await signGdprErasureRequest(
  process.env.GDPR_ERASURE_SECRET!,
  timestamp,
  JSON.stringify(payload),
);

const response = await fetch(
  `${process.env.BETTER_AUTH_URL}/api/internal/gdpr-erasure/storage`,
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-gdpr-timestamp": timestamp,
      "x-gdpr-signature": signature,
    },
    body: JSON.stringify(payload),
  }
);

const receipt = await response.json();
// receipt.ok === true indicates successful worker-side purging

```

## Environment Configuration

| Variable | Required By | Purpose |
|----------|-------------|---------|
| `GDPR_ERASURE_SECRET` | Script + Worker | HMAC key for request signing |
| `BETTER_AUTH_URL` | Script | Worker endpoint base URL |
| `DATABASE_HOST` | Script | PostgreSQL connection target |
| Loops/PostHog/Autumn API keys | Script | Third-party service authentication |

## Summary

- **Inventory-first design**: The `buildInventory()` function prevents accidental multi-user organization deletion through strict membership validation
- **Cryptographically secured worker calls**: `signGdprErasureRequest` in [`src/shared/gdpr-erasure.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/gdpr-erasure.ts) ensures only authorized operators can trigger storage purge
- **Idempotent vendor operations**: Loops, PostHog, and Autumn deletions gracefully handle already-absent records
- **Transactional integrity**: PostgreSQL cleanup uses single-transaction execution with post-commit verification
- **Complete audit trail**: Every phase outputs structured JSON suitable for GDPR compliance documentation

## Frequently Asked Questions

### What happens if a user belongs to an organization with multiple members?

The inventory phase throws an immediate error. OpenSEO enforces a **solo-member policy** for GDPR erasure—shared organizations must be exited or dissolved before deletion proceeds. This prevents collateral data loss for other users.

### How does the worker verify that the erasure request is legitimate?

The worker validates the **HMAC-SHA256 signature** computed with the shared `GDPR_ERASURE_SECRET`. The signature covers both timestamp (replay attack prevention) and payload content. Requests with invalid signatures or expired timestamps receive `401 Unauthorized`.

### Can I run the erasure script against production without making changes?

Yes. **Dry-run mode is the default**. Executing `pnpm gdpr:erase-user --email <addr>` without `--execute` performs full inventory and external vendor lookups without triggering deletions. Always review the JSON inventory output before adding execution flags.

### What storage systems are covered beyond PostgreSQL?

The worker endpoint purges **R2 object storage** (audit artifacts), **KV namespaces** (progress caching), **Durable Objects** (audit workflows and sessions), and revokes **Google OAuth grants**. Cloudflare Workers logs and workflow state expire per account retention policies rather than explicit deletion.