How v10r satisfies EU data-subject rights: one aggregator defines "all my data", many surfaces consume it, and consent gates anything written to the device.
What is it?
A privacy domain ($lib/server/privacy/) that is the single source of truth for everything the app knows about a user. Every right-of-access, portability, and erasure surface calls it — so the definition of "all my data" cannot drift between them.
| Right (GDPR Article) | Surface |
|---|---|
| Access — Art 15 | /account/data page + GET /api/me/data |
| Portability — Art 20 | GET /api/me/data/export (JSON download) |
| Erasure — Art 17 | DELETE /api/me + account-page typed-confirmation form |
| Consent — TDDDG §25 / ePrivacy Art 5(3) | Consent banner, consent-gated analytics cookie |
| Transparency — Art 13 | Login-page "How we handle your data" link |
The privacy aggregator
collectUserData(userId, { currentSessionId }) returns a versioned PersonalDataReport — every domain that holds user data, in one object. deleteUserData(userId) is the matching Art 17 erasure.
Five surfaces, one definition:
/account/datapage load (streamed)GET /api/me/dataGET /api/me/data/exportDELETE /api/me
Import direction: the read aggregator (report.ts collectUserData) reads ONLY from $lib/server/db (the import sink) — never from peer domains — keeping it framework-free and reusable by any adapter (page, REST, future AI tool). The erasure mutation (mutations.ts deleteUserData) additionally imports deleteUserGraph from $lib/server/graph/rag because Postgres CASCADE cannot reach Neo4j; that is the one sanctioned cross-domain reach in this module.
Hard rules (compliance, not style)
These are enforced in privacy/report.ts, not left to callers:
- Secrets never leave the query layer. OAuth tokens,
session.token, and the password column are projected to presence/scope/expiry booleans at the query — the report cannot accidentally echo a credential. - Prior-session IPs are masked to 2 octets (v4) / 2 groups (v6). A shared device means a historical IP can be a third party's data (Art 15(4)). The current session is shown raw — it is the requester's own connection.
- Sections degrade independently. A
settle()wrapper makes one failed read render that section unavailable; it never 500s the whole report. - Per-domain legal basis + Art 20
portabletag. Portability applies only to consent/contract data; the report marks which sections it covers. - Analytics comes in two lanes, and only one belongs in the report. The authenticated lane (
analytics.user_events, keyed by user id) IS included as thebehaviorsection — plainly this user's data under Art 15, reported on a legitimate-interest basis and marked portable. The anonymous lane (analytics.events, keyed by hashedvisitorId) is deliberately absent: re-identifying that hash needs a documented Art 6(4) basis that does not exist. Do not add it without one — and note that joining the two lanes would not merely widen this report, it would move the anonymous lane onto a different legal footing entirely. See blueprint/analytics/two-lane-model.md. - The
securitysection is contract, never portable. It reportstwoFactorEnabled+ passkey display metadata only — never a secret, backup code, public key, credential ID, or raw AAGUID.REPORT_SCHEMA_VERSIONis bumped (2026-06-17) when section shape changes. Erasure needs no change: passkey/two-factor rows FK-cascade withauth.user. - The
imagessection reports awithGpsCount. The Image Metadata Reader treats location as opt-in (see ../../blueprint/ai/image-metadata.md); the aggregator can count persisted-location records because GPS lives in a typedgps_lat/gps_lngcolumn, never only inside a blob. Stored image derivatives are EXIF-stripped, so no GPS exists outside this opt-in column.
Erasure is the FK cascade — plus a Neo4j sweep
deleteUserData removes the auth.user row. Every user-keyed Postgres table references it onDelete: 'cascade' (sessions, accounts, preferences, conversations, desk, notification links, comments, palettes, RAG documents), so the cascade IS the relational erasure. Idempotent — deleting an already-deleted user returns 204. Analytics rows are untouched: they are keyed by hashed visitorId, never by user id. The feedback table has no userId (anonymous by design) and is not part of the user inventory.
Neo4j has no foreign keys, so the cascade cannot reach it. deleteUserData therefore also calls deleteUserGraph(userId) to sweep the user's per-tenant :Chunk and :Entity nodes from the RAG graph. The sweep is best-effort — wrapped in try/catch so an Aura outage logs and continues rather than blocking the authoritative relational erasure. See ../../blueprint/ai/layered-rag.md.
The transparency page
/account/data is the authenticated "Your data" mirror (Art 15). SSR-only, Cache-Control: no-store, private.
- Instant data (no DB cost): cookies on the device (security-relevant values redacted to presence-only), the live IP / User-Agent / Accept-Language the server sees right now.
- Streamed report:
collectUserDatais returned as an unawaited promise; the page renders each section via{#await}as it resolves. - Emptiness-first ordering, reveal-gated, consent-tier control, export/manage buttons. Localized en/de/ru (
app_data_*keys).
First-signup redirect
app/+layout.server.ts sends each user to /account/data?welcome=1 exactly once, right after first sign-in.
The marker is app.user_preferences.transparency_seen_at (nullable timestamptz). consumeTransparencyMarker() claims it atomically with a single INSERT … ON CONFLICT DO UPDATE … setWhere isNull … RETURNING — exactly-once and prefetch-safe, so a SvelteKit prefetch racing a navigation cannot trigger the redirect twice. The gate self-excludes the target path (the page consumes the marker on a direct first visit). Every other nav pays only a cheap PK read.
The marker column is declared in the schema SSOT (app/user-preferences.ts). It was first applied to the live DB by running the additive ALTER TABLE … ADD COLUMN IF NOT EXISTS directly through the neon() driver rather than db:push, because drizzle-kit's interactive nullsNotDistinct re-prompt can't be piped — a fresh db:push reproduces the column from the schema either way.
What the admin can do
Transparency runs both ways: data subjects get the rights above; this section discloses what the person holding /admin/* — a single environment-pinned role, not a role hierarchy — can and cannot do with the data.
Scope
Admin-only surfaces (non-admins 404, see guarantees) group into four buckets: Observe (DB/analytics dashboards — aggregate only, no raw IPs; the audit log; feedback triage), Manage (ban users, toggle feature flags), Content (CRUD posts/tags), System (inspect/re-run jobs, configure notification channels, AI usage, flush cache). None of these read secrets.
Guarantees (code-enforced, not policy)
| Guarantee | Mechanism |
|---|---|
| Nothing to escalate | ADMIN_USER_ID (comma-separated user ids) is the only gate — no is_admin column, no role table |
| 404, not 403 | requireAdmin / guardApiAdmin return a generic 404 for non-admins; admin routes don't disclose their own existence |
| Append-only audit | Every admin write calls recordAuditEvent into admin.audit_log; that module exposes no update/delete path, and the row has no FK on actorId so the entry survives the actor's own account deletion |
| No write without consent | Cron jobs that mutate user-facing data run behind a Bearer token known only to Vercel Cron, never exposed in any UI |
Technically prevented vs. process-prevented
Some limits are enforced in code; others depend on process outside the app:
- Technically prevented — reading a raw IP (never persisted, see the transparency-page masking rules above), reading a password (Argon2id is one-way), erasing the audit log (no UPDATE/DELETE code path), joining an analytics journey for a user who rejected consent (no
session_idto join atnecessarytier) - Process-prevented — self-promotion to admin. The gate is an env var, not a database row: granting admin needs Vercel project access and a redeploy, which is an operational control, not a code one
Consent-gated analytics cookie
The _v10r_sid analytics session cookie writes to terminal equipment and is not strictly necessary, so under TDDDG §25 / ePrivacy Art 5(3) it requires prior consent.
analytics/hook.ts enforces this:
- At
analyticstier or higher → set/read the_v10r_sidcookie as before. - At
necessarytier (or no consent) → touch no cookie, actively delete a stale one from a prior grant, and derive the session id withderiveCookielessSessionId(visitorId)=hash(visitorId + UTC day)(Plausible/Fathom pattern, rotates at UTC midnight).
Session counting still works without writing to the device — page views within one UTC day group into one session.
Stack advantages
| Component | Benefit |
|---|---|
| Better Auth | Self-hosted FK cascade IS the erasure; no external deletion request |
| Drizzle | One typed query layer; secret columns projected out at the query |
| Neon | EU region available |
| Cookieless fallback | Analytics works at necessary tier with nothing on the device |
Known limitations
- AI conversations are counted in the report (count + token total) but not yet individually exportable or selectively deletable; full erasure removes them via cascade.
- Backups must eventually purge deleted data — out of scope for the in-app cascade.
- Vendor DPAs / SCCs (AI providers, Neon, R2) are an operational obligation, not enforced in code. See ../vendors.md.
Related
- ../../foundation/user-data.md - Data category definitions
- ../../codebase-organization.md -
privacy/domain location,api/me/group - ../../system-abstraction.md - privacy as a multi-client core (one aggregator, five surfaces)
- ../../blueprint/analytics/activation.md - analyticsCollector consent gating
- ../auth/better-auth.md - user-data ownership and cascade