How OpenSEO Handles GDPR Storage Erasure: Complete Technical Implementation

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), which consumes shared utilities from [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).

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 (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) 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


# 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:

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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →