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
- Generate millisecond timestamp:
const timestamp = String(Date.now()) - Serialize payload with canonical ordering
- Compute HMAC:
crypto.createHmac('sha256', secret).update(timestamp + payload).digest('hex') - Transmit via headers
x-gdpr-timestampandx-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:
- Purges invitation and verification records by email
- Removes GSC/GA4 connection rows and API keys
- Anonymizes audit attribution (preserving aggregate analytics)
- Deletes single-member organizations
- 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:
signGdprErasureRequestinsrc/shared/gdpr-erasure.tsensures 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →