User data a buyer will accept
Acquisition-ready user-data schema (consent + metrics + export)
For when the answer to “I might sell this business one day” is yes.
Structures user data so it is a SELLABLE asset, not a liability — the retention/conversion metrics a buyer's due diligence asks for, AND the consent-to-transfer + deletion layer that makes the data legally sellable at all.
Skip the rebuild
You do not write this one. It arrives in your project as ordinary source you can read, change and keep, with its audit record and its caveats beside it.
- Download the Playground. It is free and runs on your own machine.
- Start a new app and tick “I might sell this business one day”.
- This block is written in, along with anything else you ticked.
The mistake it removes
The half that's usually missing is the expensive one: without a record that each user consented to their data transferring on acquisition, the user base can't legally be included in a sale — and dirty, non-deletable data gets a deal discounted or killed. This block ships that half. It also ships the Supabase Row Level Security most schemas forget — without it, the auto-generated API lets any logged-in user read everyone's data.
What is already handled
Each of these was checked by running the code, not by reading it.
- all 5 SQL files parse against the real PostgreSQL grammar (pglast / libpg_query)
- sql/rls.supabase.sql is READ on every run and must still say it: every table gets FORCE ROW LEVEL SECURITY + an owner-only policy (auth.uid() = user_id for both read and write), applied via a guarded loop that skips tables an app doesn't have. That is a property of the SQL TEXT this block ships — nothing here applies it to a database, so whether your Postgres enforces it depends on your running the migration.
- sql/core.sql is READ on every run and must still declare anonymize_user SECURITY DEFINER with a LOCKED search_path (public, pg_temp) — so a deletion works under RLS without opening the search_path privilege-escalation hole. Again a property of the shipped SQL text, and not of a database this repository ran it against.
- 62 node assertions (counted here: test/data-asset-kit.test.mjs, +9 with initdb) on the runtime helpers and the function grants, all passing
- anonymize_user, record_mrr_change and acq_required_spend_categories are REVOKED from PUBLIC, anon and authenticated right after each CREATE, and only service_role is granted anonymize_user and acq_required_spend_categories (ph104tsk25). Read from the SQL text on every run. The same read refuses a written GRANT that hands one of those functions back to PUBLIC, anon or authenticated, whether it sits at the top level of a file, inside a function or DO body quoted with dollar signs or with single quotes, or in a string passed straight to EXECUTE: ON FUNCTION or ON ROUTINE with or without an argument list, ON ALL FUNCTIONS or ALL ROUTINES IN SCHEMA public, a grantee spelled TO GROUP, a trailing GRANTED BY, and a role membership that makes one of them a member of service_role, postgres, supabase_admin or any role this SQL grants a guarded function to (ph104tsk134). A string used as a value (after IS, a RAISE level, SELECT, RETURN, THEN, ELSE, an opening bracket, a comma or an operator) is text, so a GRANT written inside one is not refused. Any other single-quoted string is treated as text too, and any other dollar-quoted string is read as SQL to be safe. A GRANT assembled at run time with format(), || or a variable is not read. With initdb on PATH the files are also applied to a throwaway PostgreSQL where anon and a signed-in user calling anonymize_user get permission denied and service_role still erases.
- recordConsent validates the consent type, the boolean, and policy_version; re-consent is idempotent via ON CONFLICT
- hasTransferConsent returns true ONLY for the user's most-recent GRANTED data_transfer_on_acquisition — withdrawn and never-given both correctly exclude the user from a sale
- exportUserData bundles the user row + every per-user table, skips a value table the app doesn't have without crashing, and is timestamped. The user row is read by named columns (user_id, email, current_tier, created_at, updated_at, is_active, deleted_at), so password_hash never reaches a portability bundle or a data room (ph104tsk29): asserted against a store double on every run, and against the real schema when initdb is on PATH.
- deleteUser exports the user's data FIRST, then calls anonymize_user rather than deleting the rows — both halves asserted by name in test/data-asset-kit.test.mjs against a store double. That the anonymized aggregates then SURVIVE for cohort metrics is a property of anonymize_user inside sql/core.sql, and nothing here applies that SQL to a database: deleteUser is only as good as anonymize_user, which is a gap and not a pass.
- a malicious perUserTables entry ("users; DROP TABLE users --") is rejected by an identifier guard, never interpolated into a query
What the audit found
Named rather than summarized. The reasoning behind each one ships inside the block, so it travels with the code instead of living on a page you have to trust.
- The block records transfer consent but your Terms must actually grant it major · Legal & compliance pages
- SQL parses but was not executed against a live Postgres minor · Data durability
- Telemetry is only a clean asset if analytics consent is recorded minor · Privacy & data
What you still have to do
A copied file cannot own your secrets, your host or your legal obligations. This is the part the block deliberately does not claim.
- On raw Postgres you must enforce per-user access in your own server code — rls.supabase.sql uses auth.uid() and only applies to Supabase. Keep the analytics views behind the service role: they read across all users and must never be granted to the client.
- List YOUR value tables in perUserTables and stamp every consent with policy_version. Export and erasure walk the tables you name; a table you forget is silently absent from both.
- The database URL or Supabase service-role key must come from your host's secret store. This block takes an injected query() and never reads an environment variable itself, so credential custody is entirely yours.
- Your database's own backup and point-in-time-recovery settings. The block structures the data and ships the export path; keeping the bytes is the host's job, and anonymize_user is not reversible.
- Publish the privacy and terms documents the recorded consents point at, and have a lawyer confirm they actually grant transfer on acquisition in your jurisdiction. The block records that the user agreed to a version; it cannot tell you the version said what you needed it to say.
- NOT LEGAL ADVICE. This encodes the mechanics due diligence checks (consent records, transfer consent, erasure, export) — a real sale still needs a lawyer to confirm your terms actually grant transfer and your jurisdiction's rules (GDPR/CCPA/etc.) are met.
- The SQL parses but was NOT run against a live Postgres here (none available). Apply it to a scratch database once before production; cross-file order matters (core.sql before any value-tables file).
- Collecting behavioral telemetry to prove value to a buyer is only clean if users consented to analytics — record that consent (the block's `analytics` type) or you're building the liability you're trying to avoid.
- anonymize_user rewrites the email to a placeholder and keeps aggregate rows; if your jurisdiction requires hard deletion of a specific field, extend it.
- With Supabase Auth, leave password_hash NULL and reference auth.users — do not run a second credential store. RLS depends on this: `auth.uid() = user_id` only means 'my row' if user_id IS the auth id.
What lands in your project
sql
- sql/core.sql
- sql/acquisition.sql
- sql/value_tables.vibecoder.sql
- sql/value_tables.authorge.sql
- sql/rls.supabase.sql
Postgres/Supabase migrations, applied IN ORDER: (1) core.sql — users, consent, daily usage; (1b) acquisition.sql — the revenue layer (subscriptions/products + MRR/margin/concentration views) the Acquisition Standard reads; then — users, consent, profiles, daily usage, deletion, retained-cohort view; (2) ONE value-tables file for your domain (vibecoder = projects/deployments; authorge = manuscripts/publications); (3) rls.supabase.sql LAST if on Supabase — enables Row Level Security so the auto-generated API can't leak every user's data. On raw Postgres with no auth.uid(), skip step 3 and enforce ownership in your own server code.
node
- node/data-asset.js
createDataAsset({ query }) returns { recordConsent, hasTransferConsent, transferableCounts, exportUserData, deleteUser }. DB-agnostic — pass a query(text, params) fn (pg pool.query shape).
What you supply
- query — a (text, params) => Promise<{ rows }> — node-postgres pool.query, or a thin Supabase adapter
- policy_version — stamp every consent with the version of the terms/privacy doc the user saw
- perUserTables — override if your value tables differ from the vibecoder/authorge defaults, so export + deletion cover them
Licensed MIT. It is a starting point, not a finished product.
Get it
Download the Playground See the other blocks
Nothing here is locked. The files are yours, in your folder, under a permissive license.
Questions
- Is User data a buyer will accept audited?
- It was audited on 2026-07-18, and the verdict was not a clean pass.
- What do I still have to do myself?
- On raw Postgres you must enforce per-user access in your own server code — rls.supabase.sql uses auth.uid() and only applies to Supabase. Keep the analytics views behind the service role: they read across all users and must never be granted to the client.
- How do I get this code?
- Download the Playground, start a new app, and tick “I might sell this business one day”. The block is written into your project as ordinary source you can read and edit.
All pre-built code blocks · Learn to build from zero · The coding guide