From 4744cc7a142d18d2ea58ddaa5dfa56d91cdd8bd6 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:03:22 +0200 Subject: [PATCH 1/3] docs(legal): a privacy policy that matches the code, one retention per store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The privacy section had drifted behind a year of features. It promised "no personal data (names, emails, etc.)" while the feedback widget asks for exactly that and keeps it; it named no legal basis and no jurisdiction; its rights list stopped at access/rectify/erase/export; and nothing said that Cloudflare processes the visitor's IP at the edge. Each store now carries the retention that is true for it — Cloud Logging's 30 days, a feedback entry until it is deleted by hand — rather than one blanket figure. Plausible is described by the property that matters (no cookies, no cross-site identifier, no IP stored, the browser talking only to our domain) instead of the false claim that the script is not a third party's. The crawler body in api/routers/seo.py mirrored the same wrong sentence and is corrected with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --- api/routers/seo.py | 6 ++- app/src/pages/LegalPage.test.tsx | 35 ++++++++++++++++ app/src/pages/LegalPage.tsx | 64 ++++++++++++++++++++---------- changelog.d/legal-privacy-audit.md | 16 ++++++++ tests/unit/api/test_routers.py | 5 +++ 5 files changed, 103 insertions(+), 23 deletions(-) create mode 100644 changelog.d/legal-privacy-audit.md 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..6b32c25f9a9 100644 --- a/app/src/pages/LegalPage.test.tsx +++ b/app/src/pages/LegalPage.test.tsx @@ -50,6 +50,41 @@ 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. + expect(screen.getByText(/no personal data unless you type it/)).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..b47a995390f 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 user-agent, 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 personal data unless you type it — the optional contact field in the feedback + widget is the only one that asks for a name or an email
- • 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 — and a + cached release number) +
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..3d91148bc97 --- /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. diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index b42333106ba..23442654f0e 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -1167,6 +1167,11 @@ 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 + assert "kept 30 days" 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 From 8bcc27b2c4ef20e73ce8ca45e5f65f1ed7122298 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:04:27 +0200 Subject: [PATCH 2/3] docs(changelog): reference the PR in the fragment Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --- changelog.d/legal-privacy-audit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/legal-privacy-audit.md b/changelog.d/legal-privacy-audit.md index 3d91148bc97..c5024c11a9d 100644 --- a/changelog.d/legal-privacy-audit.md +++ b/changelog.d/legal-privacy-audit.md @@ -13,4 +13,4 @@ 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. + corrected with it (#11217). From 6f6b6100a1d71f3a783a96736e6a1047da6a5d8f Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:09:11 +0200 Subject: [PATCH 3/3] docs(legal): the feedback record in full, and a narrower no-personal-data line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the Copilot review, all of them right: * The feedback paragraph read as an exhaustive list but left out the window size and the random session id the widget sends with every submission (FeedbackWidget.tsx:213-214). Both are named now. * "no personal data unless you type it" was the old blanket claim in new words. An IP address, its hash and a persistent random id are personal data whether or not anyone typed them, and the same page says three paragraphs earlier that all three are stored. The bullet now promises only what it can: no name or email unless you type one. * The localStorage inventory listed preferences and a cached release number, but not `anyplot_feedback_session` — a persistent random identifier, which is the one entry a reader would want listed. * The crawler-body test claimed to pin both retentions and asserted only the 30 days. It now asserts the feedback one too. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --- app/src/pages/LegalPage.test.tsx | 12 ++++++++++-- app/src/pages/LegalPage.tsx | 14 +++++++------- tests/unit/api/test_routers.py | 3 +++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/src/pages/LegalPage.test.tsx b/app/src/pages/LegalPage.test.tsx index 6b32c25f9a9..7bee39bbdbc 100644 --- a/app/src/pages/LegalPage.test.tsx +++ b/app/src/pages/LegalPage.test.tsx @@ -77,8 +77,16 @@ describe('LegalPage', () => { render(); // The feedback widget asks for "Name or email (optional)", so the old - // blanket "no personal data" was false. - expect(screen.getByText(/no personal data unless you type it/)).toBeInTheDocument(); + // 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(); diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx index b47a995390f..c0ca4d8b4c3 100644 --- a/app/src/pages/LegalPage.tsx +++ b/app/src/pages/LegalPage.tsx @@ -237,9 +237,9 @@ export function LegalPage() { 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 user-agent, 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. + 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 @@ -251,11 +251,11 @@ export function LegalPage() { • no user accounts or personal profiles
- • no personal data unless you type it — the optional contact field in the feedback - widget is the only one that asks for a name or an email + • 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 (localStorage holds UI preferences — theme, image size — and a - cached release number) + • 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 diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index 23442654f0e..0d2a3282391 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -1171,7 +1171,10 @@ def test_seo_legal_names_operator_and_privacy_facts(self, client: TestClient) -> # 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