Skip to content

Commit aeac5c3

Browse files
docs(legal): a privacy policy that matches the code, one retention per store (#11217)
## Summary - **The privacy section now says what the code does.** It had drifted behind a year of features; every claim in it (and the services table behind it) was checked against the current code and infrastructure, and what no longer held was fixed or dropped. Same lowercase house voice, same length class — the section grew by three short paragraphs, not by an article-by-article rewrite. - **One retention per store, none borrowed.** Server logs get Cloud Logging's 30 days; a feedback entry gets "nothing deletes them on a timer — ask and yours is gone". A single blanket figure is the qualifier a shortening pass loses first, so the tests pin both separately. - **The crawler body in `api/routers/seo.py` carried the same wrong sentence** ("no personal data collected") and is corrected in the same commit — that string is anyplot's prerender for `/legal`, and its own comment asks for the two to stay in sync. ## What was added | | | |---|---| | legal basis + jurisdiction | one sentence under `data controller`: an operator's legitimate interest in protecting the site and knowing how often it is read; Swiss data protection law, GDPR on top for EU visitors. No article numbers, no EU-representative claim. | | feedback retention | a new `feedback.` paragraph: every field the widget stores (message, reaction, contact, page, window size, user agent, submission id, IP hash), admin-only, no timer | | the edge | a new `the edge.` paragraph: Cloudflare processes the IP for rate limiting, bot filtering (which can show a check page) and DDoS | | rights | access, rectification, erasure, restriction, objection **on grounds relating to your particular situation**, complaint to a supervisory authority — one sentence, no article numbers | ## What was wrong and is now fixed Each claim with the evidence that decided it: | Claim as it stood | Verdict | Evidence | |---|---|---| | "no personal data (names, emails, etc.)" | **false** → "no name or email unless you type one — the optional contact field in the feedback widget is the only place that asks" | `app/src/components/FeedbackWidget.tsx:485` placeholder is literally `Name or email (optional)`; stored at `core/database/models.py:250` (`contact`), alongside `message`, `viewport`, `user_agent`, `session_id`, `ip_hash` (`models.py:248–259`). The promise is deliberately *only* name/email: an IP address, its hash and the feedback session id are personal data whether or not anyone typed them, so any broader "no personal data" wording would be the same false claim again (Copilot review) | | Plausible "collects no personal data … a privacy-focused analytics tool" | **wrong property** → no cookies, no cross-site identifier, no IP kept there, the browser talks only to us | the script IS Plausible's, only served from our domain: `app/nginx.conf:321–323` proxies `/js/script.js`, `:335` proxies `/api/event`; `app/index.html:143,145` | | "all services are hosted in the EU (Netherlands, europe-west4)" | **over-broad** → the site, database and images run in europe-west4; Cloudflare's network is global; Plausible is its own EU service | `api/cloudbuild.yaml:4` `_REGION: europe-west4`, `:66` `anyplot:europe-west4:anyplot-db`; `gcloud storage buckets list` → `anyplot-images`, `anyplot-static` both `EUROPE-WEST4` | | "we track … UI interactions (tab toggles, theme preference, **banner dismissals**)" | **stale** → list shortened to what is actually sent | the tracked event names are `copy_code, download_image, external_link, feedback_opened, feedback_submitted, internal_link, library_click, library_filter, map_*, nav_click, page_not_found, plot_rotate, report_issue, search_no_results, stats_top_impl_click, suggest_spec, tag_click, theme_toggle` — no banner event exists | | "since we do not store personal data, there is typically nothing to delete or export" | **false**, and `export` was the wrong right | feedback entries exist; and portability applies to consent/contract processing, not to the legitimate interest this site runs on, so the export claim was dropped rather than restated | | "no cookies at all (localStorage for UI preferences only)" | **true, but the inventory was short** | no `Set-Cookie` from `anyplot.ai` or `api.anyplot.ai` (curl, 2026-09-03). localStorage keys: `theme` (`useThemeMode.ts:3`), `imageSize` (`PlotsPage.tsx:100`), `anyplot:latest-release` (`useLatestRelease.ts:4`) and `anyplot_feedback_session` (`FeedbackWidget.tsx:25,144`). The last two are not preferences — one is a cached release number, the other a persistent random identifier — so both are named (Copilot review) | | "no ai training: your interactions are not used to train ai models" | **true, sharpened** | Anthropic is called only from `scripts/evaluate-plot.py:500` (CI/generation time). `grep anthropic api/ core/` finds only the unused `core/config.py:79` key — nothing on a request path. The bullet now says so | | bot body: "no personal data collected" (`api/routers/seo.py`) | **false** → same per-store wording as the human page | same evidence as row 1 | | "last updated: July 2026" | stale → September 2026 | | ## What was checked and left alone - **server logs, 30 days** — `gcloud logging buckets describe _Default --location=global --project=anyplot` → `retentionDays: 30`. This is the configured value (and also GCP's default), so the sentence stands as written. - **the services table** — every row verified accurate (Cloud Run, Cloud SQL, Cloud Storage all `europe-west4`; Cloudflare global with EU data centres; Plausible EU + proxied). Only the sentence introducing it was over-broad. - **the public Plausible dashboard** — `https://plausible.io/anyplot.ai` answers 200. - **GitHub username credit in spec metadata** — real: `plots/*/specification.yaml` carries `suggested: <github handle>`. Paragraph kept unchanged. - **CSP violation reports** — anyplot has **no** CSP reporting: `app/security-headers.conf:28` sets no `report-uri`/`report-to`, and the only `report-to` header on the live site is Cloudflare's own NEL endpoint, which reports to Cloudflare, not to us. Nothing was written about it, since there is nothing to write. (The sibling repo does have one; this is the asymmetry, not an omission.) - **an in-process rate-limit counter** — anyplot has none. The only rate limit is the feedback endpoint's, and it is DB-backed via the stored `ip_hash` (`api/routers/feedback.py:96–101`), so it is covered by the feedback paragraph rather than getting one of its own. ## For Markus — please confirm 1. **The jurisdiction sentence.** "swiss data protection law applies, and the GDPR on top for visitors from the EU", with no EU representative named. That is the intended position, as agreed for kurrentschrift — say the word if anyplot should read differently. 2. **The one figure I could not verify from the repository:** the 30-day log retention comes from the live `_Default` Cloud Logging bucket, not from any file in the repo. If a sink or a bucket policy is ever changed in the console, this sentence goes stale silently — there is no test that can catch it. 3. **The feedback contact field is a genuine personal-data intake nobody had documented.** Free text plus "Name or email (optional)" is stored indefinitely and read only through the admin `/debug` routes (`api/routers/debug.py:547,560,599`, all `require_admin`). The page now says so honestly, but if you would rather the field stopped asking for an email, or entries got an automatic expiry, that is a code change and a separate PR. ## Decisions taken here - Kept the existing skeleton (`data controller` · `what we collect` · `what we do not collect` · `hosting & third parties` · `your rights`) rather than adding sub-headings — the brief asked for a focused update, not a new page. - Folded the legal basis into `data controller` instead of giving it its own heading; one sentence did not earn one. - Dropped the right to data portability rather than restating it, per the reasoning in the table above. - Named neither article numbers nor per-region service essays anywhere. ## Review round One Copilot review, four findings, all legitimate and all applied in `6f6b610`: the feedback field list was missing the window size and the submission id; "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 page says so three paragraphs earlier); the localStorage inventory omitted `anyplot_feedback_session`; and the crawler-body test claimed to pin both retentions while asserting only the 30 days. Both threads answered and resolved. ## Test plan - [x] `yarn type-check` (tsc + tsconfig.test.json) — clean - [x] `yarn lint` — clean - [x] `yarn fm:check` — clean - [x] `yarn test` — 70 files, 626 tests pass; `LegalPage.test.tsx` grew from 9 to 12 cases, pinning the legal basis + jurisdiction, the per-store retentions, and the corrected claims (including the two feedback identifiers, so a later trim cannot drop them) - [x] `uv run pytest -k seo_legal` — passes; the test now also asserts the old wrong sentence is gone - [x] `uv run ruff check` + `ruff format --check` on the touched Python — clean - [x] `uv run python -m tools.changelog check --base origin/main` — fragment well-formed 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 908e65a commit aeac5c3

5 files changed

Lines changed: 114 additions & 23 deletions

File tree

api/routers/seo.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,8 +1054,10 @@ async def seo_libraries():
10541054
'<a href="https://x.com/MarkusNeusinger">X</a>). Full legal notice with '
10551055
'contact details on the <a href="https://anyplot.ai/legal">interactive page</a>.</p>'
10561056
"<h2>Privacy</h2>"
1057-
"<p>Analytics: Plausible Analytics (EU, proxied) — no cookies, no personal data collected. "
1058-
"Hosting: Google Cloud Run (Netherlands).</p>"
1057+
"<p>Analytics: Plausible Analytics (EU), served over a path on this domain — no cookies, no "
1058+
"cross-site identifier, no IP address stored. Server logs are kept 30 days; a feedback entry "
1059+
"is kept until it is deleted by hand and is readable only by the operator. Hosting: Google "
1060+
"Cloud Run (Netherlands), with Cloudflare at the edge.</p>"
10591061
"<h2>Transparency</h2>"
10601062
"<p>The whole stack — specs, pipeline, API and frontend — is open source at "
10611063
'<a href="https://github.com/MarkusNeusinger/anyplot">github.com/MarkusNeusinger/anyplot</a>; '

app/src/pages/LegalPage.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,49 @@ describe('LegalPage', () => {
5050
expect(plausibleLinks.length).toBeGreaterThan(0);
5151
});
5252

53+
// The privacy section's load-bearing claims, each pinned where it is true.
54+
// A shortening pass is exactly what drops a qualifier — "30 days" silently
55+
// spreading over a store that has no timer, or the objection right losing
56+
// the condition that makes it one.
57+
it('names the legal basis, the jurisdiction and the whole set of rights', () => {
58+
render(<LegalPage />);
59+
60+
expect(screen.getByText(/legitimate interest in protecting the site/)).toBeInTheDocument();
61+
expect(screen.getByText(/swiss data protection law applies/)).toBeInTheDocument();
62+
expect(
63+
screen.getByText(/object to the processing on grounds relating to your particular situation/)
64+
).toBeInTheDocument();
65+
expect(screen.getByText(/complain to a supervisory authority/)).toBeInTheDocument();
66+
});
67+
68+
it('gives each store its own retention, and no store a borrowed one', () => {
69+
render(<LegalPage />);
70+
71+
// Cloud Logging has the 30 days; the feedback table has no timer at all.
72+
expect(screen.getByText(/retained for 30 days/)).toBeInTheDocument();
73+
expect(screen.getByText(/nothing deletes them on a timer/)).toBeInTheDocument();
74+
});
75+
76+
it('does not claim more privacy than the code delivers', () => {
77+
render(<LegalPage />);
78+
79+
// The feedback widget asks for "Name or email (optional)", so the old
80+
// blanket "no personal data" was false — and so is any restatement of it:
81+
// an IP address, an IP hash and the feedback session id are personal data
82+
// whether or not you typed them, so the bullet promises only name/email.
83+
expect(screen.getByText(/no name or email unless you type one/)).toBeInTheDocument();
84+
// Everything the feedback record actually holds, so a later trim cannot
85+
// quietly drop the two identifiers a reader would care about most.
86+
expect(screen.getByText(/your window size/)).toBeInTheDocument();
87+
expect(
88+
screen.getByText(/a random id that ties your own submissions together/)
89+
).toBeInTheDocument();
90+
// Plausible's real property is cookieless and identifier-free, not that
91+
// the script is ours — it is Plausible's, only served from our domain.
92+
expect(screen.getByText(/the script is theirs/)).toBeInTheDocument();
93+
expect(screen.getByText(/Cloudflare stands in front of the site/)).toBeInTheDocument();
94+
});
95+
5396
it('renders the technology stack', () => {
5497
render(<LegalPage />);
5598

app/src/pages/LegalPage.tsx

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -188,22 +188,26 @@ export function LegalPage() {
188188

189189
<Box sx={{ maxWidth: 760, mx: 'auto' }}>
190190
<Typography sx={subheadingStyle}>data controller</Typography>
191-
<Typography sx={textStyle}>Markus Neusinger (see legal notice above)</Typography>
191+
<Typography sx={textStyle}>
192+
Markus Neusinger (see legal notice above). the ground for all of it is an
193+
operator&apos;s legitimate interest in protecting the site and knowing how often it is
194+
read; swiss data protection law applies, and the GDPR on top for visitors from the EU.
195+
</Typography>
192196

193197
<Typography sx={subheadingStyle}>what we collect</Typography>
194198
<Typography sx={textStyle}>
195-
<strong>anonymized analytics.</strong> we use{' '}
199+
<strong>anonymized analytics.</strong> the counting is done by{' '}
196200
<Link href="https://plausible.io" target="_blank" rel="noopener" sx={proseLinkStyle}>
197201
Plausible Analytics
198202
</Link>
199-
, a privacy-focused analytics tool. it collects no personal data, uses no cookies, and
200-
does not track you across websites. we track: page views, navigation patterns, code
201-
copies, image downloads, search queries, filter usage, UI interactions (tab toggles,
202-
theme preference, banner dismissals), and anonymized performance metrics (Core Web
203-
Vitals: LCP, CLS, INP) to keep the site fast. when you share a link, we read the
204-
requesting bot&apos;s user-agent to detect the platform (e.g., LinkedIn, WhatsApp) —
205-
no data about the eventual viewer is collected at that step. all data is aggregated
206-
and anonymous.
203+
. the script is theirs, but it is served over a path on this domain: no cookies, no
204+
identifier that would recognize you on other sites, no IP address kept there, and your
205+
browser talks only to us. counted are page views, search queries and filter usage,
206+
code copies and image downloads, theme and navigation clicks, and Core Web Vitals
207+
(LCP, CLS, INP) to keep the site fast. when you share a link, we read the requesting
208+
bot&apos;s user-agent to detect the platform (e.g., LinkedIn, WhatsApp) — no data
209+
about the eventual viewer is collected at that step. all data is aggregated and
210+
anonymous.
207211
</Typography>
208212
<Typography sx={textStyle}>
209213
<strong>public dashboard.</strong> our analytics are{' '}
@@ -230,16 +234,31 @@ export function LegalPage() {
230234
</Link>{' '}
231235
for security and debugging purposes.
232236
</Typography>
237+
<Typography sx={textStyle}>
238+
<strong>feedback.</strong> the feedback widget keeps what you send it: the message,
239+
the reaction, anything you put in the optional contact field, the page you were on,
240+
your window size, your user-agent, a random id that ties your own submissions
241+
together, and a one-way hash of your IP address that limits spam. only the operator
242+
can read those entries, and nothing deletes them on a timer — ask and yours is gone.
243+
</Typography>
244+
<Typography sx={textStyle}>
245+
<strong>the edge.</strong> Cloudflare stands in front of the site and processes your
246+
IP address there to rate-limit, to filter bots — which can put a short check page in
247+
front of a request — and to absorb DDoS attacks.
248+
</Typography>
233249

234250
<Typography sx={subheadingStyle}>what we do not collect</Typography>
235251
<Typography sx={textStyle}>
236252
• no user accounts or personal profiles
237253
<br />
238-
• no personal data (names, emails, etc.)
254+
• no name or email unless you type one — the optional contact field in the feedback
255+
widget is the only place that asks
239256
<br />
240-
• no cookies at all (we use localStorage for UI preferences only)
241-
<br /><strong>no ai training</strong>: your interactions are not used to train ai
242-
models
257+
• no cookies at all (localStorage holds UI preferences — theme, image size — a cached
258+
release number, and, once you use the feedback widget, the random id above)
259+
<br /><strong>no ai training</strong>: nothing you send is used to train ai models.
260+
the ai in this project writes the catalogue, at build time; it never sees a
261+
visitor&apos;s request
243262
</Typography>
244263

245264
<Typography sx={textStyle}>
@@ -250,7 +269,9 @@ export function LegalPage() {
250269

251270
<Typography sx={subheadingStyle}>hosting &amp; third parties</Typography>
252271
<Typography sx={textStyle}>
253-
all services are hosted in the EU (Netherlands, europe-west4):
272+
the site, its database and its images run in the EU (Netherlands, europe-west4);
273+
Cloudflare&apos;s network is global, and Plausible is a service of its own on its own
274+
EU servers:
254275
</Typography>
255276
<Table sx={{ ...tableStyle, ...firstColStyle }}>
256277
<TableBody>
@@ -279,13 +300,14 @@ export function LegalPage() {
279300

280301
<Typography sx={subheadingStyle}>your rights</Typography>
281302
<Typography sx={textStyle}>
282-
you have the right to access, rectify, erase, and export your data. since we do not
283-
store personal data, there is typically nothing to delete or export. for questions,
284-
contact{' '}
303+
you can ask what is stored about you and have it corrected, erased or its use
304+
restricted, object to the processing on grounds relating to your particular situation,
305+
and complain to a supervisory authority. in practice that means a feedback entry or a
306+
log line; a few lines to{' '}
285307
<Link href="mailto:admin@anyplot.ai" sx={proseLinkStyle}>
286308
admin@anyplot.ai
287-
</Link>
288-
.
309+
</Link>{' '}
310+
are enough.
289311
</Typography>
290312
</Box>
291313
</Box>
@@ -554,7 +576,7 @@ export function LegalPage() {
554576
mt: 2,
555577
}}
556578
>
557-
last updated: July 2026
579+
last updated: September 2026
558580
</Typography>
559581
</Box>
560582
</>

changelog.d/legal-privacy-audit.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
### Changed
2+
3+
- **The privacy policy says what the code actually does, per store.** The section had drifted
4+
behind a year of features: it promised "no personal data (names, emails, etc.)" while the
5+
feedback widget asks for exactly that ("Name or email (optional)") and keeps it, with the
6+
message, the page, the user agent and an IP hash, in a table nothing prunes on a timer. It
7+
also had no legal basis, no jurisdiction, and a rights list missing restriction, objection
8+
and the complaint to a supervisory authority. Each store now carries its own retention —
9+
Cloud Logging's 30 days, a feedback entry until it is deleted by hand — because a single
10+
blanket figure is the qualifier a shortening pass loses first. Plausible is described by the
11+
property that matters (no cookies, no cross-site identifier, no IP stored, the browser
12+
talking only to our domain) rather than the false one it used to claim (the script is
13+
Plausible's, only served from here), Cloudflare's edge processing of the IP is named, and
14+
the "all services in the EU" line no longer swallows a global CDN and a separate analytics
15+
service. The crawler body in `api/routers/seo.py` mirrored the same wrong sentence and was
16+
corrected with it (#11217).

tests/unit/api/test_routers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,14 @@ def test_seo_legal_names_operator_and_privacy_facts(self, client: TestClient) ->
11671167
assert "Plausible Analytics" in response.text
11681168
assert "no cookies" in response.text
11691169
assert "Google Cloud Run" in response.text
1170+
# The crawler body used to promise "no personal data collected", which
1171+
# the feedback widget's optional contact field made false. It now
1172+
# carries the same per-store retention the human page does.
1173+
assert "no personal data collected" not in response.text
1174+
# BOTH retentions, not only the logs' — pinning just the 30 days is how
1175+
# that figure silently spreads over the store it is not true for.
1176+
assert "kept 30 days" in response.text
1177+
assert "until it is deleted by hand" in response.text
11701178

11711179
def test_seo_mcp_tells_agents_how_to_connect(self, client: TestClient) -> None:
11721180
"""The /mcp page's audience is AI agents — the bot body must carry the

0 commit comments

Comments
 (0)