BUILT INTO EVERY GOODSPEED APP
GDPR Data Export + Account Deletion Lifecycle
User-initiated export emails a signed JSON download link. Account deletion has a configurable grace window, recovery flow, immediate-delete, and purge cron.
- Tier: Common
- Status: Config-toggled
- Config: features.compliance.gdpr.enabled
WHY IT MATTERS
GDPR Article 20 grants every EU user the right to receive a copy of their personal data in a portable format. Article 17 grants the right to erasure. Most apps treat both as an afterthought, bolting on a manual CSV export and a hard delete button when a compliance audit forces the issue. The gas-template ships both flows from day one, wired end-to-end rather than as stub buttons that land in a support inbox. When a user requests a data export, the request-data-export Edge Function inserts a row in data_export_requests with status pending and immediately enqueues a build_data_export background job. The job in build-data-export/handler.ts iterates over every table that holds user data, from profiles and push_tokens to credit_ledger and consent_log, and queries each table by user_id in parallel. The results are serialized to JSON, compressed with gzip using Deno's native gzipSync, and uploaded to a private Supabase Storage bucket. A 7-day signed URL is generated from the uploaded object and emailed to the user through the transactional-email pipeline. The user receives a download link that expires automatically. No support ticket, no manual intervention, no plaintext data sitting in an email attachment.
Account deletion is more nuanced than a single DELETE call. A user who requests deletion after an accidental tap should not lose their account permanently. The template implements a configurable grace window via getComplianceConfig() in supabase/functions/_shared/compliance-config.ts. When accountDeletionGraceDays is set (the default is 30), the request-account-deletion function stamps the user's profile row with pending_deletion_at and delete_scheduled_for rather than deleting anything. A transactional email informs the user of the scheduled date and includes a recovery link. If the user signs in again before the grace window expires, the cancel-account-deletion function clears the pending_deletion_at field and logs the recovery event to account_deletion_log. When the grace window passes, the daily purge-pending-deletions cron queries for profiles whose delete_scheduled_for is in the past and fans out one purge_account job per user. The purge job hard-deletes the user row in Supabase Auth, which cascades via RLS-enforced foreign keys to all dependent tables. Every state transition in the lifecycle is written to account_deletion_log so you have an auditable record of the full lifecycle.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from supabase/functions/build-data-export/handler.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// supabase/functions/build-data-export/handler.ts
// Background job: query all user tables, gzip, upload to Storage,
// generate a 7-day signed URL, and email the download link.
const EXPORT_TABLES = [
'profiles', 'push_tokens', 'notifications', 'user_bookmarks',
'feedback', 'consent_log', 'credit_balances', 'credit_ledger',
'transactions', 'cost_usage', 'account_deletion_log', 'data_export_requests',
];
const SIGNED_URL_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
export async function handleBuildDataExport(raw: Record<string, unknown>) {
const { requestId, userId } = raw as { requestId: string; userId: string };
const client = serviceClient();
await client.from('data_export_requests').update({ status: 'processing' }).eq('id', requestId);
const results = await Promise.all(EXPORT_TABLES.map(async (table) => {
const col = table === 'profiles' ? 'id' : 'user_id';
const { data, error } = await client.from(table).select('*').eq(col, userId);
return { table, data: data ?? [], error };
}));
const payload: Record<string, unknown[]> = {};
for (const { table, data } of results) payload[table] = data;
const gz = gzipSync(new TextEncoder().encode(
JSON.stringify({ exported_at: new Date().toISOString(), user_id: userId, data: payload }, null, 2),
));
const storagePath = `${userId}/${requestId}.json.gz`;
await client.storage.from('data-exports').upload(storagePath, gz, { contentType: 'application/gzip', upsert: true });
const { data: signed } = await client.storage.from('data-exports')
.createSignedUrl(storagePath, SIGNED_URL_TTL_SECONDS);
// update data_export_requests row, then enqueue send_email job with signed URL
}Source: goodspeed-apps/gas-template → supabase/functions/build-data-export/handler.ts
HONEST LIMITS
When GDPR Data Export + Account Deletion Lifecycle is the wrong choice
The 30-day grace window is a UX choice, not a legal requirement, and it is the wrong default for several regulated verticals. In banking and financial services, many jurisdictions interpret GDPR erasure as requiring prompt deletion upon request, with 'prompt' meaning days, not a month. A 30-day window in those contexts exposes you to regulatory risk. Similarly, HIPAA-covered healthcare apps in the US may be subject to patient rights provisions that require timely erasure independently of GDPR. If your app processes protected health information or regulated financial data, set accountDeletionGraceDays to 0 in your compliance config so the grace window collapses to an immediate purge. The allowImmediateDeletion flag in compliance-config.ts provides a user-facing immediate-delete path even when the default grace window is nonzero, which can satisfy users who need a faster exit without eliminating the safety net for everyone else. The export pipeline queries every table listed in EXPORT_TABLES in parallel. For accounts with large data volumes, say a user who has accumulated thousands of rows across ledger and audit tables, the export job can exceed Supabase Edge Function timeout limit (150-second cap) and produce very large gzip archives. If your app accumulates high per-user row counts, consider paginating the table queries or streaming the export in chunks rather than loading all rows into memory at once. The current implementation is correct for apps with typical personal-data volumes (hundreds to low thousands of rows per user) but is not designed for accounts holding tens of thousands of rows across multiple wide tables.
Tier: Common · Config-toggled
Evaluate your use case
Check whether gdpr data export + account deletion lifecycle aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.compliance.gdpr.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes gdpr data export + account deletion lifecycle. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
GDPR Data Export + Account Deletion Lifecycle capability breakdown
Concrete dimensions of what the built-in gdpr data export + account deletion lifecycle implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Export format | All user rows across every tracked table are serialized to a single JSON envelope and compressed with gzip before upload. The envelope includes exported_at timestamp, user_id, and a per-table map of row arrays. | Gzipped JSON |
| Download delivery | A 7-day signed URL is generated from the Supabase Storage object and delivered via the transactional-email pipeline. The URL expires automatically without any cleanup job. | 7-day signed URL via email |
| Deletion grace window | accountDeletionGraceDays in compliance-config.ts controls the delay between a deletion request and the actual purge. Defaults to 30 days. Setting it to 0 collapses the window to an immediate purge. | Configurable (default 30 days) |
| Recovery flow | Signing in during the grace window triggers cancel-account-deletion, which clears the pending_deletion_at field and logs a cancelled event to account_deletion_log. No support ticket required. | On-login recovery |
| Audit trail | Every lifecycle state transition is written to account_deletion_log. The export workflow also calls writeAuditLog() on completion, producing a dual-trail via both tables. | account_deletion_log + audit_log |
COMMON QUESTIONS
Does the data export capture every table, including app-specific tables the DevAgent generated?
The EXPORT_TABLES array in build-data-export/handler.ts lists every base-template table by name. When DevAgent generates an app-specific schema, it appends the new table names to that array so the export automatically includes app-specific user data. The query logic uses a user_id column by default (and id for the profiles table), which matches the column naming convention enforced across all DevAgent-generated tables. If a generated table uses a different user-linking column name, you can extend the query logic in handler.ts to pass a custom column override per table. The export job logs a warning for any table query that returns an error but does not fail the entire export, so a single missing or mis-named table does not prevent the user from receiving the rest of their data. All errors are surfaced in the data_export_requests row under the error column.
How does the recovery flow work if a user changes their mind after requesting deletion?
As long as the delete_scheduled_for timestamp has not passed, the user can recover their account by signing in again. The app checks the pending_deletion_at field on the profile row during the post-login session setup step. If the field is set, the app renders a recovery confirmation screen rather than navigating to the home screen. Confirming recovery calls the cancel-account-deletion Edge Function, which clears both pending_deletion_at and delete_scheduled_for on the profiles row, logs a cancelled event to account_deletion_log with a timestamp, and writes an audit log entry so the recovery is attributable. The user's data is fully intact throughout the grace window: no rows are modified, anonymized, or queued for deletion until the purge-pending-deletions cron fans out the final purge job after the grace date has passed.
What does the daily purge cron actually delete, and is it reversible?
The purge-pending-deletions cron runs once per day and queries profiles for rows where delete_scheduled_for is in the past. For each matching profile it enqueues a purge_account job. The purge_account job calls Supabase Auth's admin.deleteUser(userId), which hard-deletes the auth.users row. Because all app tables link to auth.users via a user_id foreign key with ON DELETE CASCADE (enforced in the gas-template migrations), the cascade removes every dependent row in every table automatically. This deletion is not reversible. There is no soft-delete layer underneath the purge path. The account_deletion_log row is the only record that the user ever existed. If your app needs a reversible purge for legal hold or fraud investigation purposes, extend purge_account to archive the user's data to a separate cold-storage bucket before calling admin.deleteUser.
Can I offer the data export to users in non-EU countries, and does it create any obligations?
Offering data export to all users regardless of geography is generally advisable and creates no additional legal obligations beyond those you already have. California (CCPA), Brazil (LGPD), Canada (PIPEDA), and other privacy frameworks include similar data portability rights with varying scope. Providing a universal export capability satisfies all of them simultaneously and avoids the complexity of geolocating users to decide who gets the feature. The signed URL delivery is safe to use globally: the URL is scoped to a single Supabase Storage object, expires after 7 days, and is delivered over HTTPS. The gzip archive is not encrypted beyond the HTTPS transport layer, so if your threat model includes a compromised email account you should consider adding an application-layer passphrase to the archive or delivering the key separately. The export pipeline does not currently implement passphrase-based encryption; that is an extension to handleBuildDataExport if your risk assessment requires it.
GET IT BUILT INTO YOUR APP