Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions api/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1054,8 +1054,10 @@ async def seo_libraries():
'<a href="https://x.com/MarkusNeusinger">X</a>). Full legal notice with '
'contact details on the <a href="https://anyplot.ai/legal">interactive page</a>.</p>'
"<h2>Privacy</h2>"
"<p>Analytics: Plausible Analytics (EU, proxied) — no cookies, no personal data collected. "
"Hosting: Google Cloud Run (Netherlands).</p>"
"<p>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.</p>"
"<h2>Transparency</h2>"
"<p>The whole stack — specs, pipeline, API and frontend — is open source at "
'<a href="https://github.com/MarkusNeusinger/anyplot">github.com/MarkusNeusinger/anyplot</a>; '
Expand Down
43 changes: 43 additions & 0 deletions app/src/pages/LegalPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LegalPage />);

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(<LegalPage />);

// 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(<LegalPage />);

// 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(<LegalPage />);

Expand Down
64 changes: 43 additions & 21 deletions app/src/pages/LegalPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,22 +188,26 @@ export function LegalPage() {

<Box sx={{ maxWidth: 760, mx: 'auto' }}>
<Typography sx={subheadingStyle}>data controller</Typography>
<Typography sx={textStyle}>Markus Neusinger (see legal notice above)</Typography>
<Typography sx={textStyle}>
Markus Neusinger (see legal notice above). the ground for all of it is an
operator&apos;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.
</Typography>

<Typography sx={subheadingStyle}>what we collect</Typography>
<Typography sx={textStyle}>
<strong>anonymized analytics.</strong> we use{' '}
<strong>anonymized analytics.</strong> the counting is done by{' '}
<Link href="https://plausible.io" target="_blank" rel="noopener" sx={proseLinkStyle}>
Plausible Analytics
</Link>
, 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&apos;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&apos;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.
</Typography>
<Typography sx={textStyle}>
<strong>public dashboard.</strong> our analytics are{' '}
Expand All @@ -230,16 +234,31 @@ export function LegalPage() {
</Link>{' '}
for security and debugging purposes.
</Typography>
<Typography sx={textStyle}>
<strong>feedback.</strong> 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.
</Typography>
<Typography sx={textStyle}>
<strong>the edge.</strong> 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.
</Typography>

<Typography sx={subheadingStyle}>what we do not collect</Typography>
<Typography sx={textStyle}>
• no user accounts or personal profiles
<br />
• 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
<br />
• no cookies at all (we use localStorage for UI preferences only)
<br />• <strong>no ai training</strong>: 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)
<br />• <strong>no ai training</strong>: 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&apos;s request
</Typography>

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

<Typography sx={subheadingStyle}>hosting &amp; third parties</Typography>
<Typography sx={textStyle}>
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&apos;s network is global, and Plausible is a service of its own on its own
EU servers:
</Typography>
<Table sx={{ ...tableStyle, ...firstColStyle }}>
<TableBody>
Expand Down Expand Up @@ -279,13 +300,14 @@ export function LegalPage() {

<Typography sx={subheadingStyle}>your rights</Typography>
<Typography sx={textStyle}>
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{' '}
<Link href="mailto:admin@anyplot.ai" sx={proseLinkStyle}>
admin@anyplot.ai
</Link>
.
</Link>{' '}
are enough.
</Typography>
</Box>
</Box>
Expand Down Expand Up @@ -554,7 +576,7 @@ export function LegalPage() {
mt: 2,
}}
>
last updated: July 2026
last updated: September 2026
</Typography>
</Box>
</>
Expand Down
16 changes: 16 additions & 0 deletions changelog.d/legal-privacy-audit.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 8 additions & 0 deletions tests/unit/api/test_routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down