Skip to main content

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/data page load (streamed)
  • GET /api/me/data
  • GET /api/me/data/export
  • DELETE /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 portable tag. 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 the behavior section — plainly this user's data under Art 15, reported on a legitimate-interest basis and marked portable. The anonymous lane (analytics.events, keyed by hashed visitorId) 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 security section is contract, never portable. It reports twoFactorEnabled + passkey display metadata only — never a secret, backup code, public key, credential ID, or raw AAGUID. REPORT_SCHEMA_VERSION is bumped (2026-06-17) when section shape changes. Erasure needs no change: passkey/two-factor rows FK-cascade with auth.user.
  • The images section reports a withGpsCount. 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 typed gps_lat/gps_lng column, 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: collectUserData is 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_id to join at necessary tier)
  • 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

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 analytics tier or higher → set/read the _v10r_sid cookie as before.
  • At necessary tier (or no consent) → touch no cookie, actively delete a stale one from a prior grant, and derive the session id with deriveCookielessSessionId(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.
← Back to Stack

Think this pattern could be better? Tell us how.

Leave feedback