@softure-ai/privacy
v0.2.1
Published
SOFTURE AI privacy module: modules and the app register GDPR export and deletion contributors; a JSON export endpoint and self-service account deletion for Next.js.
Readme
@softure-ai/privacy
GDPR self-service and consent evidence for a Next.js app: a signed-in user downloads everything the app stores about them as JSON, or deletes their account, and neither lists tables by hand; every consent is recorded with the version of the legal document it was given to; legal pages render from the app's own text. Every enabled module that holds user data contributes its own export and deletion; the app adds its own contributors. Built from an app where the tables of the export and the deletion were listed by hand, deletion ran only from a CLI, the registration checkbox was checked but nothing was stored, and the legal pages had their own document frame and footer.
1. What it provides
- A contributor registry. Each module states what it holds through
defineModule({ privacy: { exportUserData, deleteUserData } })(@softure-ai/core, checked against its manifest'sprivacyflags); the app addsprivacy({ contributors }). A module is in the export and the deletion by being enabled. - The export.
GET /api/privacy/exportanswers a JSON attachment (account-data-<date>.json) with every contributor's part under its id, read in one repeatable-read transaction, bounded byexport.maxBytes. - The deletion. A page with the export link and a delete form that asks for the current
password and an explicit confirmation. Every contributor deletes in one transaction; a refusal or
a failure rolls everything back. The sessions end with the account, the cookie is cleared, and the
browser lands on
afterDelete. - The consent ledger.
privacy.consentsrecords who agreed to what, when, where and against which version of which legal document. It is append-only: a withdrawal is a new row, and the database refuses to update one. A ready hook records the registration checkbox of@softure-ai/auth; other modules (the waitlist) record their own scopes. - Copying an account between databases.
copyAccount({ from, to, userId })copies one account with every row it owns, found through foreign keys, with exact values; a verified dry run by default.createCopyAccountScript()in/scriptsruns it as an ops command (section 11). - Legal documents with their history. A document is declared with its versions in force, or
with its change history, from which the version follows;
getDocumentVersionAtanswers the version in force on a past day, for a backdated consent. - The legal document shell.
LegalDocument,LegalSectionandLegalFooterrender the app's terms and privacy policy: version, effective date, table of contents, change history.
@softure-ai/auth, @softure-ai/feature-switches and @softure-ai/mcp-access contribute already (their README section 11).
2. Installation
npm install @softure-ai/privacy @softure-ai/auth @softure-ai/security @softure-ai/core @softure-ai/db @softure-ai/ui drizzle-ormPeer dependencies: next 16, react 19, drizzle-orm; @softure-ai/ops only for the
copy-account script in @softure-ai/privacy/scripts (section 11). The module depends on auth (the account
and the session) and security (rate limits): a configuration without them fails at startup.
3. Configuration
import { auth, AUTH_RATE_LIMIT_BUCKETS } from "@softure-ai/auth";
import { privacy, PRIVACY_RATE_LIMIT_BUCKETS } from "@softure-ai/privacy";
import { security } from "@softure-ai/security";
import { deleteProfile, exportProfile } from "./lib/profile-privacy";
// in defineSoftureConfig({ modules: [...] }):
security({ clientIp: cloudflareIp(), buckets: { ...AUTH_RATE_LIMIT_BUCKETS, ...PRIVACY_RATE_LIMIT_BUCKETS } }),
auth({ ... }),
auth({ onRegistered: recordRegistrationConsent() }), // from @softure-ai/privacy/server
privacy({
documents: [
{
id: "terms",
history: [
{ date: "2026-10-01", summary: "Added the newsletter." }, // the version in force: 2026-10-01
{ date: "2026-01-15", summary: "First version." },
],
},
{ id: "privacy-policy", version: "2026-10-01" },
],
contributors: [{ id: "profile", exportUserData: exportProfile, deleteUserData: deleteProfile }],
}),| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| contributors | AppPrivacyContributor[] | [] | The app's own data: { id, exportUserData?, deleteUserData? }, at least one function each. |
| contributors[].id | string | required | Kebab-case, at most 64 characters, unique, and not the id of an enabled module: it is the key of the contributor's part of the export. |
| documents | { id, version?, history? }[] | [] | The app's legal documents and the versions in force. A consent names a document by id and privacy records this version with it. Change version whenever the published text changes (a date such as 2026-10-01 works well), or add a history entry. |
| documents[].id | string | required | Kebab-case, at most 64 characters, unique, e.g. terms, privacy-policy. |
| documents[].version | string | required without history | 1-32 letters, digits, ., _ or -. With history it may be left out; given, it must equal the newest entry's version, so a forgotten bump fails at startup. |
| documents[].history | { date, summary, version? }[] | none | Every published text, in any order; at least one entry. date (YYYY-MM-DD, unique) is the day it takes effect, summary says what changed, version defaults to the date. The version in force is the newest entry's. Add an entry on the day its text takes effect: until then getDocumentVersionAt(config, id, now) answers the earlier version, but consents are stamped with the newest one. |
| export.maxBytes | number | 10485760 (10 MiB) | The largest export, in bytes of JSON (1 KiB to 100 MiB). A larger one is refused with privacy.export_too_large. |
| export.fileName | string | account-data | The download's name before the date. |
| routes | { account, export, afterDelete } | /account/privacy, /api/privacy/export, / | Where the page and the route are mounted, and where a deleted account lands. |
| messages | partial dictionaries per locale (en built in; any other is the app's own translation) | built in | Copy overrides (section 9). |
A contributor is two functions over the module context ({ db, clock, config }, db being the
transaction) and the user id:
import { ok, type ModuleContext } from "@softure-ai/core";
import type { Queryable } from "@softure-ai/db";
import { eq } from "drizzle-orm";
import { profiles } from "../db/schema";
export async function exportProfile(context: ModuleContext, userId: string) {
const db = context.db as Queryable;
const [profile] = await db.select().from(profiles).where(eq(profiles.userId, userId));
return ok({ displayName: profile?.displayName ?? null });
}
export async function deleteProfile(context: ModuleContext, userId: string) {
await (context.db as Queryable).delete(profiles).where(eq(profiles.userId, userId));
return ok();
}Rules for a contributor:
- Query through
context.dbonly: it is the transaction. A query on another handle is outside the rollback and, under Postgres, waits on the transaction's locks. exportUserDataonly reads: the export runs in a read-only transaction. Export plain JSON values (dates become ISO strings), and never secrets: password hashes, token hashes, API keys.- Delete (or anonymise) everything that names the user. To keep data for a legal reason, return an
ErrfromdeleteUserData: nothing is deleted, the user seesprivacy.deletion_refused, and the contributor's id and code are logged. - Throw only for bugs and database failures; both roll the deletion back.
Order. The export runs the modules in the config's dependency order (dependencies first), then
the app's contributors in their listed order. The deletion runs the reverse: the app's contributors
first (last listed first), then each module before the modules it depends on, auth last. A row is
therefore always deleted before the rows it references, whatever its foreign key's ON DELETE.
Consents
import { getConsent, hasConsent, listConsents, recordConsent } from "@softure-ai/privacy/server";
await recordConsent(ctx, { subject: { userId }, purpose: "newsletter", granted: true, source: "account" });
await recordConsent(ctx, { subject: { email }, purpose: "newsletter", granted: false, source: "unsubscribe" });
await recordConsent(ctx, { subject: { userId }, purpose: "terms", granted: true, document: "terms", source: "account" });
await hasConsent(ctx, { subject: { userId }, purpose: "terms" }); // latest row granted, current versionsubjectis an account ({ userId }) or an email address without one ({ email }, stored as a SHA-256 key, never as the address;{ emailKey }names the same subject by that key, for a caller that holds only the key, such as mailing's unsubscribe hook). Reads keep the two apart; the export and the deletion of an account cover both, so a waitlist consent given before registering goes with the account.purposeandsourceare kebab-case (at most 64 characters);documentnames a declared document, whose configured version is recorded. A bad subject, purpose or source givesprivacy.consent_invalid, an undeclared documentprivacy.document_unknown.- A withdrawal is
granted: false: a new row, the earlier one stays as evidence. The state of a purpose is its latest row:getConsentreturns it withisCurrentVersion(false once the document's configured version moved on, or the document is no longer declared),hasConsentis true only for a granted row of the current version,listConsentsgives the whole history. - Call these inside the transaction of the action they belong to (
ctx.dbbeing the transaction), so the action and its evidence commit together.
History from before the ledger. importConsent(ctx, { ...input, recordedAt, documentVersion? })
records a consent or withdrawal at the time it was given, for an app that moves its own records onto
the ledger (the waitlist's importSignups uses it). recordedAt must not be after now;
documentVersion (1 to 64 visible ASCII characters, only with document) records the version the
person agreed to instead of the configured one, so an older text stays an older-version consent
(hasConsent false, and the next consent records the current version). Without documentVersion,
a document declared with a history records the version in force at recordedAt, or the
configured one when recordedAt precedes the history. It writes every call: a caller that may run
twice checks listConsents first.
The version at a date. getDocumentVersionAt(config, id, at) (/server) is the version in
force on a calendar day ("2026-05-31") or at an instant (a Date, read as the day in the
config's timezone): the newest history entry dated on or before it. It is undefined before the
first entry; a document declared without history answers its one version for any day.
Registration. recordRegistrationConsent({ documents? }) is an onRegistered hook for auth:
when the app keeps requireConsent on, it records one row per document (purpose = the document's
id, source registration) at the account's creation time, in the account's transaction. By
default it records every declared document; documents: ["terms"] narrows it. With no document
declared, or an undeclared one named, it throws and the registration rolls back: an account never
exists without the evidence of its consent.
4. Mounting
// app/account/privacy/page.tsx: the export link and the delete form; redirects to login without a session.
export { PrivacyPage as default } from "@softure-ai/privacy/next";
export const dynamic = "force-dynamic";// app/api/privacy/export/route.ts: the JSON download for the session's user.
export { exportRoute as GET } from "@softure-ai/privacy/next";Legal pages are the app's own pages around LegalDocument, with the text from the app's
dictionaries or content files and the version from the config:
// app/legal/terms/page.tsx
import { getPrivacyMessages } from "@softure-ai/privacy/next";
import { getLegalDocument } from "@softure-ai/privacy/server";
import { LegalDocument } from "@softure-ai/privacy/ui";
import config from "../../../softure.config";
import { terms } from "../../../content/terms";
export default function TermsPage() {
return (
<LegalDocument
title={terms.title}
version={getLegalDocument(config, "terms").version}
effectiveFrom="2026-10-01"
sections={terms.sections} // [{ id: "your-account", title: "2. Your account", content: <p>…</p> }]
changes={terms.changes} // newest first: [{ version, date: "2026-10-01", summary }]
messages={getPrivacyMessages(config)}
locale={config.locale}
/>
);
}With a history in the config, pass the declaration instead of version, effectiveFrom and
changes: document={getLegalDocument(config, "terms")} renders the version line with the newest
entry's date, and the history newest first (an entry whose version is its date shows the date
alone). changes, given, still replaces the declared history, for summaries the app translates.
An app whose page frame already shows the title and its own "in force" sentence leaves out title
and passes meta={null} (or its own node, meta={<p>…</p>}) instead of version and
effectiveFrom: the shell then renders no <h1> and adds no sentence. A history entry needs only a
summary; version and date are rendered when given. For a contents column beside the text, put
a grid on root, header across both columns, and the contents and body slots side by side;
contentsTitleAs="p" turns the contents heading into a label and listChangesInContents links the
history last:
<LegalDocument
meta={null}
sections={terms.sections}
changes={[{ summary: "Changed 23 September 2026: paid access." }]}
listChangesInContents
contentsTitleAs="p"
classNames={{
root: "lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-x-16", // the app's own classes
contents: "lg:sticky lg:top-24 lg:self-start",
}}
messages={getPrivacyMessages(config)}
locale={config.locale}
/>LegalFooter (links: [{ href, label }], an optional note) goes into the root layout. Inside the
app's own <footer>, pass as="div" (or as="nav" for the navigation alone) so there is no footer
inside a footer; separator=" · " puts a separator, hidden from assistive technology, between the
links. For one line of text inside the app's own footer or under a form, pass as="p" (or
as="span"): the links and separators become inline content with no navigation and no list, the
separator (default " · ", null for none) is read as text, and the note follows the links in a
span.
LegalDocument names its contents by a generated id, so two documents can share a page; give each
its own history anchor with changesId (default legal-changes). as sets the root element
(article by default, div or section).
Link the page from the app's account page. To build your own page, compose DeleteAccountForm
from @softure-ai/privacy/ui with deleteAccountAction from /next, and link to the export route.
The route answers 401 without a session, 429 with retry-after over the privacy-export limit,
413 over export.maxBytes and 500 when a contributor fails, each with { error: <code> }. The
action reads the user from the session before it reads the form, and Next refuses it from another
origin. Outside a request, call collectUserData(ctx, userId) and eraseUserData(ctx, userId)
from /server (for example from an operator script); they apply no rate limit and ask for no
password.
5. Migrations and tables
Schema privacy, one migration:
| Table | Columns | Notes |
| --- | --- | --- |
| consents | id (identity), user_id (FK auth.users, cascade) or email_key (base64url SHA-256 of the trimmed, lowercased address), purpose, granted, document_id + document_version (both or neither), source, recorded_at | exactly one subject (consents_one_subject); a BEFORE UPDATE trigger refuses every update; indexed by subject, purpose and time |
The export and the deletion run every other contributor on its own tables. GET /api/health of
@softure-ai/ops checks that the table answers.
6. Environment variables
None.
7. Switches
None.
8. Appearance
The page is built from @softure-ai/ui (Card, ButtonLink, PasswordField, Checkbox,
FormError, Button with the danger variant) and uses only its compiled classes, so
@softure-ai/ui/styles.css styles it and the --sft-* tokens theme it. DeleteAccountForm takes
classNames for its slots (root, description, form) and unstyled.
The legal components are server components with the same rules. LegalDocument slots: root,
header, title, meta, intro, contents, contentsTitle, contentsList, link, body (the
sections and the history), changes, changesTitle, changesList, change, changeMeta, and
sectionClassNames for every LegalSection (root, title, body). LegalFooter slots: root,
list (the list form only), link, separator, note; the inline form (as="p", as="span")
has its own defaults, which keep only the type and colour on the root.
Sections scroll into view below a sticky header through scroll-mt; dates (YYYY-MM-DD) are
written in the app's locale, in UTC so the server's time zone cannot move them.
9. Copy
privacyMessages.en: page (title, lead), export (title, description, button), delete
(title, description, password label, confirmation, submit and pending labels), legal (contents,
version, effective from, change history, the footer's navigation name) and errors for every
code the form and the route can give (privacy.*, auth.unauthenticated, security.rate_limited,
core.*). Override any of them with
privacy({ messages: { de: { errors: { privacy: { deletion_refused: "…" } } } } }), for example to
say how to reach the app's support.
The client components (DeleteAccountForm) take their locale from the prop, then from the nearest
UiLocaleProvider of @softure-ai/ui, then en, so an app that mounts the provider once can leave the prop out.
Server components (LegalDocument and the pages) cannot read the provider and take locale as a prop.
10. Hooks
The contributors (section 3) are the hooks. recordRegistrationConsent() is a ready hook for
auth's onRegistered (section 3). The rate limit buckets, both per user, are
PRIVACY_RATE_LIMIT_BUCKETS: privacy-export (5 an hour) and privacy-delete (5 attempts in 15
minutes, each a password check). Spread them into security({ buckets }); a missing bucket throws
on first use and names it.
11. GDPR
This module is the GDPR surface: right of access (the export) and right to erasure (the deletion), and the evidence of consent (Art. 7(1)). What each module exports and deletes is in that module's section 11.
Its own part is the consent ledger. The export lists every consent of the account and of its email
address (privacy.consents, oldest first, each marked account or email; the email key is left
out). The deletion removes them: once the data is gone there is nothing left to prove consent for.
An email subject is stored only as a SHA-256 key, so the table holds no address; the key is a
pseudonym, not anonymous data, because anyone with the address can compute it.
Rate limit rows of security hold only SHA-256 prefixes of user ids and emails and are pruned two
windows after they start.
Copying an account to another database
copyAccount (@softure-ai/privacy/server) copies one account, with every row it owns, from one
database to another: an app moving an account between instances or out of a shared database. The
source keeps the account; eraseUserData there is a separate step.
// scripts/copy-account.mts: node scripts/copy-account.mts <userId> [--commit]
import { createDatabase } from "@softure-ai/db";
import { copyAccount } from "@softure-ai/privacy/server";
const [userId = "", flag] = process.argv.slice(2);
const from = await createDatabase(process.env.FROM_DATABASE_URL ?? "");
const to = await createDatabase(process.env.TO_DATABASE_URL ?? "");
try {
const result = await copyAccount({
from: from.db,
to: to.db,
userId,
commit: flag === "--commit",
exclude: ["auth.sessions"],
include: [{ table: "waitlist.signups", column: "email", matches: "email" }],
});
console.log(JSON.stringify(result, null, 2));
process.exitCode = result.ok ? 0 : 1;
} finally {
await Promise.all([from.close(), to.close()]);
}- Which rows. The account's
auth.usersrow; every row that references a copied row through a foreign key whose ON DELETE is CASCADE, RESTRICT or NO ACTION (the rows deleting the account removes or is blocked by), transitively, so a table without a user column (a payment's refund failures) comes along; and the account's email-keyed consents. A new module or app table is covered by its foreign key, with no list to maintain. A SET NULL key is a mention (the admin who granted something), not ownership. A table's foreign key to itself is not followed.excludeleaves tables out (and what only they reach);includeadds a table no foreign key ties to the account, by a column holding its id or email (waitlist.signupsbyemail). - Exact values. Every column is read as text and written back cast to its type, with the
session's time zone and date style fixed on both sides: a
timestamptzkeeps its microseconds (pgwould round it to milliseconds throughDate), a NULL stays NULL. Generated columns are left to the target, and so is a generated primary key nothing references (privacy.consents.id), which gets a new value in the source's order; a written identity or serial key moves its sequence past the copied maximum. - Dry run and verification. Without
commit: truethe copy runs whole in the target, is verified, and is rolled back; a dry run that succeeds is a copy that would succeed. The verification reads every copied table back and compares it value for value with what was written. A dry run can leave gaps in the target's sequences (a sequence never rolls back). - Refusals. Returned as
{ ok: false, error, detail }, with nothing written:privacy.copy_account_missing(no such account in the source),copy_account_exists(the target has its id or email),copy_schema_mismatch(a table or column missing in the target, or of another type),copy_reference_missing(a copied row references a row neither the copy nor the target has, such as an OAuth client),copy_conflict(a target constraint rejects a row; the detail names it),copy_unsupported(a cycle of foreign keys between tables),copy_verification_failed,copy_ledger_mismatch(the two databases' migration ledgers differ:softure.migrationsor the app's drizzle journals; a migration ran on one side only, or from another file; the detail names up to five differences, such asprivacy#1 has another checksumordrizzle.__drizzle_migrations#1760000000000 only in the target). WithonMissingReference: "null", a SET NULL key whose row the target lacks is written as NULL instead, and counted in the report. - Same migrations. A column only the target has would silently take its default (NULL) in every
copied row, so the copy first compares the ledgers and refuses when they differ: the modules'
softure.migrationsand the app's own drizzle journals, every table in thedrizzleschema shaped like drizzle's ledger (__drizzle_migrations, and a second one such asdrizzleAppMigrations(folder, { migrationsTable: "__drizzle_migrations_after" })). A drizzle row is named by its journal entry'swhen(created_at) and compared by its hash. A journal kept in another schema (migrationsSchema) is not seen.ledger: "ignore"skips the check, for a target whose extra migrations are known to be harmless. - A new email.
newEmailgives the account another address in the target (trimmed and lowercased): itsauth.usersrow and its email-keyed consents are written with it and verified as written. The target must not have an account with the new address; one with the old address is no obstacle. Rows ofincludetables keep the values they had.
The report lists every table the account reaches, in insert order, with its row count.
As an ops command. createCopyAccountScript({ exclude?, include?, onMissingReference?, ledger? })
in @softure-ai/privacy/scripts is the same copy as a safe ops script (@softure-ai/ops/scripts):
it copies from the database at --from, which it only reads, into the app's own database (the one
the ops helper opens) or, with --to=<URL>, into another database. Name the account by
--user=<id> or --email=<address> (looked up in the source); --new-email=<address> gives it
another address in the target. Without --commit it is a dry run: everything is copied and
verified, then rolled back, in the --to database too. The report gives before and after (whether the target has the account, and the copied
tables); a refusal names the error and its detail and writes nothing.
// scripts/ops/copy-account.ts (bundled and run like the migrate step, @softure-ai/ops README)
import { runOpsMain } from "@softure-ai/ops/scripts";
import { createCopyAccountScript } from "@softure-ai/privacy/scripts";
import * as configModule from "../../softure.config";
const script = createCopyAccountScript({
exclude: ["auth.sessions"],
include: [{ table: "waitlist.signups", column: "email", matches: "email" }],
});
void runOpsMain(script, configModule);# In the target's container: the source URL comes from a file, so its password stays out of argv.
node ops/copy-account.mjs --from-file=/run/secrets/source-database-url [email protected]
node ops/copy-account.mjs --from-file=/run/secrets/source-database-url [email protected] --commitBetween two databases neither of which is the app's (a copy into a fresh development database, a restore check), pass both; the app's database is then not written:
node ops/copy-account.mjs --from-file=/run/secrets/source-url --to-file=/run/secrets/target-url [email protected] [email protected] --commit--to needs an @softure-ai/ops that passes { commit } to scripts (the release after ops 0.1.9);
an older one refuses --to and writes nothing. The --to database commits inside the ops run, which
writes nothing to the app's database in that mode. Identity and serial sequences of the target can move past the copied ids even on a
dry run, since a sequence never rolls back: a gap in ids, no rows.
12. Limitations
- The export is built in memory and bounded by
export.maxBytes; there is no streaming or background export with a download link yet. - Deletion is immediate: there is no grace period and no undo. Back up the database if the app needs to restore accounts.
- No deletion by an admin or from a CLI yet; call
eraseUserDatafrom a script of the app. copyAccountholds each table's rows of the account in memory: meant for one account at a time.- No page to review or withdraw consents yet; withdrawals come from the module that asked (the
waitlist, an unsubscribe) or from the app calling
recordConsentwithgranted: false. - A new document version does not ask anyone to accept it again:
hasConsentturns false and the app decides what to show. - Legal text is the app's: the shell renders it, it does not write it.
