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