diff --git a/api/routers/seo.py b/api/routers/seo.py
index 853955c0460..375a5526061 100644
--- a/api/routers/seo.py
+++ b/api/routers/seo.py
@@ -1054,8 +1054,10 @@ async def seo_libraries():
'X). Full legal notice with '
'contact details on the interactive page.
'
"
Privacy
"
- "
Analytics: Plausible Analytics (EU, proxied) — no cookies, no personal data collected. "
- "Hosting: Google Cloud Run (Netherlands).
"
+ "
Analytics: Plausible Analytics (EU), served over a path on this domain — no cookies, no "
+ "cross-site identifier, no IP address stored. Server logs are kept 30 days; a feedback entry "
+ "is kept until it is deleted by hand and is readable only by the operator. Hosting: Google "
+ "Cloud Run (Netherlands), with Cloudflare at the edge.
"
"
Transparency
"
"
The whole stack — specs, pipeline, API and frontend — is open source at "
'github.com/MarkusNeusinger/anyplot; '
diff --git a/app/src/pages/LegalPage.test.tsx b/app/src/pages/LegalPage.test.tsx
index eaf25763520..7bee39bbdbc 100644
--- a/app/src/pages/LegalPage.test.tsx
+++ b/app/src/pages/LegalPage.test.tsx
@@ -50,6 +50,49 @@ describe('LegalPage', () => {
expect(plausibleLinks.length).toBeGreaterThan(0);
});
+ // The privacy section's load-bearing claims, each pinned where it is true.
+ // A shortening pass is exactly what drops a qualifier — "30 days" silently
+ // spreading over a store that has no timer, or the objection right losing
+ // the condition that makes it one.
+ it('names the legal basis, the jurisdiction and the whole set of rights', () => {
+ render();
+
+ expect(screen.getByText(/legitimate interest in protecting the site/)).toBeInTheDocument();
+ expect(screen.getByText(/swiss data protection law applies/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/object to the processing on grounds relating to your particular situation/)
+ ).toBeInTheDocument();
+ expect(screen.getByText(/complain to a supervisory authority/)).toBeInTheDocument();
+ });
+
+ it('gives each store its own retention, and no store a borrowed one', () => {
+ render();
+
+ // Cloud Logging has the 30 days; the feedback table has no timer at all.
+ expect(screen.getByText(/retained for 30 days/)).toBeInTheDocument();
+ expect(screen.getByText(/nothing deletes them on a timer/)).toBeInTheDocument();
+ });
+
+ it('does not claim more privacy than the code delivers', () => {
+ render();
+
+ // The feedback widget asks for "Name or email (optional)", so the old
+ // blanket "no personal data" was false — and so is any restatement of it:
+ // an IP address, an IP hash and the feedback session id are personal data
+ // whether or not you typed them, so the bullet promises only name/email.
+ expect(screen.getByText(/no name or email unless you type one/)).toBeInTheDocument();
+ // Everything the feedback record actually holds, so a later trim cannot
+ // quietly drop the two identifiers a reader would care about most.
+ expect(screen.getByText(/your window size/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/a random id that ties your own submissions together/)
+ ).toBeInTheDocument();
+ // Plausible's real property is cookieless and identifier-free, not that
+ // the script is ours — it is Plausible's, only served from our domain.
+ expect(screen.getByText(/the script is theirs/)).toBeInTheDocument();
+ expect(screen.getByText(/Cloudflare stands in front of the site/)).toBeInTheDocument();
+ });
+
it('renders the technology stack', () => {
render();
diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx
index 6208185a187..c0ca4d8b4c3 100644
--- a/app/src/pages/LegalPage.tsx
+++ b/app/src/pages/LegalPage.tsx
@@ -188,22 +188,26 @@ export function LegalPage() {
data controller
- Markus Neusinger (see legal notice above)
+
+ Markus Neusinger (see legal notice above). the ground for all of it is an
+ operator's legitimate interest in protecting the site and knowing how often it is
+ read; swiss data protection law applies, and the GDPR on top for visitors from the EU.
+ what we collect
- anonymized analytics. we use{' '}
+ anonymized analytics. the counting is done by{' '}
Plausible Analytics
- , a privacy-focused analytics tool. it collects no personal data, uses no cookies, and
- does not track you across websites. we track: page views, navigation patterns, code
- copies, image downloads, search queries, filter usage, UI interactions (tab toggles,
- theme preference, banner dismissals), and anonymized performance metrics (Core Web
- Vitals: LCP, CLS, INP) to keep the site fast. when you share a link, we read the
- requesting bot's user-agent to detect the platform (e.g., LinkedIn, WhatsApp) —
- no data about the eventual viewer is collected at that step. all data is aggregated
- and anonymous.
+ . the script is theirs, but it is served over a path on this domain: no cookies, no
+ identifier that would recognize you on other sites, no IP address kept there, and your
+ browser talks only to us. counted are page views, search queries and filter usage,
+ code copies and image downloads, theme and navigation clicks, and Core Web Vitals
+ (LCP, CLS, INP) to keep the site fast. when you share a link, we read the requesting
+ bot's user-agent to detect the platform (e.g., LinkedIn, WhatsApp) — no data
+ about the eventual viewer is collected at that step. all data is aggregated and
+ anonymous.
public dashboard. our analytics are{' '}
@@ -230,16 +234,31 @@ export function LegalPage() {
{' '}
for security and debugging purposes.
+
+ feedback. the feedback widget keeps what you send it: the message,
+ the reaction, anything you put in the optional contact field, the page you were on,
+ your window size, your user-agent, a random id that ties your own submissions
+ together, and a one-way hash of your IP address that limits spam. only the operator
+ can read those entries, and nothing deletes them on a timer — ask and yours is gone.
+
+
+ the edge. Cloudflare stands in front of the site and processes your
+ IP address there to rate-limit, to filter bots — which can put a short check page in
+ front of a request — and to absorb DDoS attacks.
+ what we do not collect
• no user accounts or personal profiles
- • no personal data (names, emails, etc.)
+ • no name or email unless you type one — the optional contact field in the feedback
+ widget is the only place that asks
- • no cookies at all (we use localStorage for UI preferences only)
- • no ai training: your interactions are not used to train ai
- models
+ • no cookies at all (localStorage holds UI preferences — theme, image size — a cached
+ release number, and, once you use the feedback widget, the random id above)
+ • no ai training: nothing you send is used to train ai models.
+ the ai in this project writes the catalogue, at build time; it never sees a
+ visitor's request
@@ -250,7 +269,9 @@ export function LegalPage() {
hosting & third parties
- all services are hosted in the EU (Netherlands, europe-west4):
+ the site, its database and its images run in the EU (Netherlands, europe-west4);
+ Cloudflare's network is global, and Plausible is a service of its own on its own
+ EU servers:
@@ -279,13 +300,14 @@ export function LegalPage() {
your rights
- you have the right to access, rectify, erase, and export your data. since we do not
- store personal data, there is typically nothing to delete or export. for questions,
- contact{' '}
+ you can ask what is stored about you and have it corrected, erased or its use
+ restricted, object to the processing on grounds relating to your particular situation,
+ and complain to a supervisory authority. in practice that means a feedback entry or a
+ log line; a few lines to{' '}
admin@anyplot.ai
-
- .
+ {' '}
+ are enough.
@@ -554,7 +576,7 @@ export function LegalPage() {
mt: 2,
}}
>
- last updated: July 2026
+ last updated: September 2026
>
diff --git a/changelog.d/legal-privacy-audit.md b/changelog.d/legal-privacy-audit.md
new file mode 100644
index 00000000000..c5024c11a9d
--- /dev/null
+++ b/changelog.d/legal-privacy-audit.md
@@ -0,0 +1,16 @@
+### Changed
+
+- **The privacy policy says what the code actually does, per store.** The section had drifted
+ behind a year of features: it promised "no personal data (names, emails, etc.)" while the
+ feedback widget asks for exactly that ("Name or email (optional)") and keeps it, with the
+ message, the page, the user agent and an IP hash, in a table nothing prunes on a timer. It
+ also had no legal basis, no jurisdiction, and a rights list missing restriction, objection
+ and the complaint to a supervisory authority. Each store now carries its own retention —
+ Cloud Logging's 30 days, a feedback entry until it is deleted by hand — because a single
+ blanket figure is the qualifier a shortening pass loses first. Plausible is described by the
+ property that matters (no cookies, no cross-site identifier, no IP stored, the browser
+ talking only to our domain) rather than the false one it used to claim (the script is
+ Plausible's, only served from here), Cloudflare's edge processing of the IP is named, and
+ the "all services in the EU" line no longer swallows a global CDN and a separate analytics
+ service. The crawler body in `api/routers/seo.py` mirrored the same wrong sentence and was
+ corrected with it (#11217).
diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py
index b42333106ba..0d2a3282391 100644
--- a/tests/unit/api/test_routers.py
+++ b/tests/unit/api/test_routers.py
@@ -1167,6 +1167,14 @@ def test_seo_legal_names_operator_and_privacy_facts(self, client: TestClient) ->
assert "Plausible Analytics" in response.text
assert "no cookies" in response.text
assert "Google Cloud Run" in response.text
+ # The crawler body used to promise "no personal data collected", which
+ # the feedback widget's optional contact field made false. It now
+ # carries the same per-store retention the human page does.
+ assert "no personal data collected" not in response.text
+ # BOTH retentions, not only the logs' — pinning just the 30 days is how
+ # that figure silently spreads over the store it is not true for.
+ assert "kept 30 days" in response.text
+ assert "until it is deleted by hand" in response.text
def test_seo_mcp_tells_agents_how_to_connect(self, client: TestClient) -> None:
"""The /mcp page's audience is AI agents — the bot body must carry the