diff --git a/api/routers/feedback.py b/api/routers/feedback.py
index 3c3a177e11..bd494c781d 100644
--- a/api/routers/feedback.py
+++ b/api/routers/feedback.py
@@ -48,8 +48,8 @@
IP_HASH_PREFIX = "hmac:"
-"""Marks a salted `ip_hash` (`_hash_ip`). Rows written before it carry an unsalted SHA-256 of
-the address, which a migration has to clear: it is left alone here."""
+"""Marks a salted `ip_hash` (`_hash_ip`). The unsalted SHA-256 hashes that rows written before it
+carried were cleared once by hand on 2026-11-02 after a snapshot; no migration is needed."""
_IP_SALT = secrets.token_bytes(32)
"""This process's salt for `_hash_ip`. Never logged and never configured, so it dies with the
process, and with it every way to tell which address a stored hash came from."""
diff --git a/api/routers/seo.py b/api/routers/seo.py
index 0b410c9c23..2763563cbd 100644
--- a/api/routers/seo.py
+++ b/api/routers/seo.py
@@ -1205,32 +1205,48 @@ async def seo_libraries():
'X). Full legal notice with '
'contact details on the interactive page.
'
"
Privacy
"
- "
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 in Google Cloud Logging "
- "in the Netherlands (europe-west4); entries written before the move on 2 November 2026 stay in "
- "Google's global log storage until their 30 days are over. A feedback entry "
- "is kept until it is deleted by hand and is readable only by the operator, and the salted "
- "hash of the IP address that limits feedback spam is cleared after ten minutes. Hosting: "
- "Google Cloud Run (Netherlands), with Cloudflare at the edge.
"
+ "
"
+ "
Swiss data protection law applies, and the GDPR for visitors from the EU.
"
+ "
Analytics: Plausible Analytics (EU), through this domain; no cookies, no cross-site id, "
+ "no IP address stored.
"
+ "
Hosting: Google Cloud Run, SQL and Storage in the Netherlands (europe-west4), Cloudflare at "
+ "the edge, which uses your IP address to rate-limit, filter bots and absorb DDoS attacks. Server "
+ "logs: kept 30 days in Google Cloud Logging in the Netherlands (europe-west4).
"
+ "
Feedback: your message and reaction, the optional contact field, the page, window size, "
+ "user-agent and a random id that ties your own submissions together; read only by the operator, "
+ "kept until you ask to delete it. The salted IP hash against spam is cleared by the next "
+ "feedback after ten minutes, and its salt dies with the server instance.
"
+ "
No cookies for visitors (admins: the Cloudflare Access login cookie), no accounts, and no "
+ "name or email unless you type one. Suggesting a plot type on GitHub may credit your public "
+ "username in its spec.
"
+ "
No AI training on what you send; browsing the catalogue never reaches a model.
"
+ "
Legal bases: legitimate interest (Art. 6(1)(f) GDPR) for the site and the assistant's "
+ "telemetry and limits; your request (Art. 6(1)(b) GDPR) for a plot; consent (Art. 6(1)(a) GDPR) "
+ "for a feedback case, withdrawable at any time.
"
+ "
Google Cloud and Cloudflare apply their data processing terms, with safeguards for "
+ "transfers outside Switzerland and the EU.
"
+ "
Rights: access, correction, erasure, restriction, objection, and a complaint to a "
+ "supervisory authority.
"
+ "
"
'
Data assistant (experimental)
'
- "
Only if you use it: the data assistant adapts a catalogue plot to data you bring. It is "
- "open to everyone as an experimental feature, and the operator may pause it at any time. What "
- "you paste and write goes to Claude models by Anthropic through Google Cloud Vertex AI in the "
- "EU; results can be wrong, and nobody checks them by hand. Sessions live in the server's memory "
- "only and end after an hour without activity. Its log lines carry no content, only a random id "
- "of your browser tab salted with a value that lives as long as the server instance; the limits "
- "per network count your IP address the same way, in memory only. Nothing is stored unless you "
- "send feedback: then the conversation, the plots, the code and a 5-row sample of your data, and "
- "the whole data file only if you tick the box (it starts unticked), go to a private bucket for "
- "180 days. A feedback case rests on your consent (Art. 6(1)(a) GDPR), given when you send it, "
- "which you can withdraw at any time by asking. The code it writes is yours to use, as is; Highcharts is free for non-commercial use, "
- "and a commercial use needs a licence from Highsoft. Google Cloud and Cloudflare process data "
- "under their data processing terms, including their safeguards for transfers outside "
- "Switzerland and the EU.
"
+ "
"
+ "
Only if you use it: open to everyone as an experimental feature, and the operator may "
+ "pause it at any time. No warranty: results can be wrong, and nobody checks them by hand.
"
+ "
Input goes to Claude by Anthropic via Google Cloud Vertex AI in the EU, under Google "
+ "Cloud's data processing terms; it is not used for training.
"
+ "
Sessions: server memory only, cleared after an hour idle.
"
+ "
Logs and telemetry hold no content; ids and IP addresses are salted with a value that lives "
+ "as long as the server instance. Telemetry: up to 180 days.
"
+ "
Stored only with feedback: the conversation, plots, code, a 5-row sample, and the data file "
+ "only if you tick the box (it starts unticked); private bucket, 180 days. The triage row stays "
+ "until you ask to delete it.
"
+ "
Its code is yours to use, as is; Highcharts is free for non-commercial use, and a commercial "
+ "use needs a licence from Highsoft.
"
+ "
"
"
Transparency
"
"
The whole stack — specs, pipeline, API and frontend — is open source at "
'github.com/MarkusNeusinger/anyplot; '
- "the catalogue content is MIT-licensed.
"
+ "the catalogue content is MIT-licensed. Running costs about $60 a month (October 2026)."
)
diff --git a/app/src/pages/LegalPage.test.tsx b/app/src/pages/LegalPage.test.tsx
index e6808fe892..45987635a2 100644
--- a/app/src/pages/LegalPage.test.tsx
+++ b/app/src/pages/LegalPage.test.tsx
@@ -1,8 +1,12 @@
-import { fireEvent } from '@testing-library/react';
+import { fireEvent, render as rtlRender } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
+import { ThemeProvider } from '@mui/material/styles';
+
import { LegalPage } from 'src/pages/LegalPage';
import { render, screen } from 'src/test-utils';
+import { theme } from 'src/theme';
const trackEvent = vi.fn();
vi.mock('src/hooks', () => ({
@@ -64,16 +68,36 @@ describe('LegalPage', () => {
it('gives each store its own retention, and no store a borrowed one', () => {
render();
- // Cloud Logging has the 30 days, in the EU since 2026-11-02; the feedback table has no timer at all.
- expect(screen.getByText(/kept for 30 days in/)).toBeInTheDocument();
- expect(
- screen.getByText(/in the Netherlands \(europe-west4\), for security and debugging purposes/)
- ).toBeInTheDocument();
- // The move happened mid-day, so the cut is the move itself, not the date.
+ // One table row per store, each with its own figure: the 30 days belong to
+ // the server logs only, and the feedback rows have no timer at all. The IP
+ // hash is cleared only when the next feedback arrives (api/routers/feedback.py
+ // `_clear_expired_ip_hashes`), so the row says so instead of a flat "ten minutes".
+ const retention: [string, string][] = [
+ ['server logs', '30 days'],
+ [
+ 'feedback IP hash',
+ 'cleared by the next feedback after ten minutes; its salt dies with the server instance',
+ ],
+ ['assistant telemetry', 'up to 180 days'],
+ ['feedback case files', '180 days'],
+ ['feedback entries, case rows', 'until you ask to delete them'],
+ ];
+ for (const [store, kept] of retention) {
+ expect(
+ screen.getByRole('row', { name: new RegExp(`^${store}\\s*${kept}$`) })
+ ).toBeInTheDocument();
+ }
+ // The label is the row's header, so a screen reader announces it before the figure.
+ expect(screen.getByRole('rowheader', { name: 'feedback IP hash' })).toBeInTheDocument();
+ // Google Cloud, its logs included, runs in the EU region.
expect(
- screen.getByText(/written before the move on 2 November 2026 stay in Google.s global/)
+ screen.getByText(
+ /Google Cloud \(Run, SQL, Storage, Logging\) in the Netherlands \(europe-west4\)/
+ )
).toBeInTheDocument();
- expect(screen.getByText(/nothing deletes them on a timer/)).toBeInTheDocument();
+ // The page states the current state only: no notes on what older entries still carry.
+ expect(screen.queryByText(/before the move/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/unsalted/)).not.toBeInTheDocument();
});
it('does not claim more privacy than the code delivers', () => {
@@ -88,70 +112,78 @@ describe('LegalPage', () => {
// 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\s+together/)
+ screen.getByText(/a random id that ties your own submissions together/)
).toBeInTheDocument();
- // The feedback's IP hash is salted and short-lived, and the older rows are named, not hidden.
- expect(screen.getByText(/clears it after ten minutes/)).toBeInTheDocument();
- expect(screen.getByText(/older, unsalted hash until it is cleared/)).toBeInTheDocument();
+ // The feedback's IP hash is salted; when it goes sits in the retention table.
+ expect(screen.getByText(/a salted hash of your IP address limits spam/)).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();
+ expect(screen.getByText(/its script is served through this domain/)).toBeInTheDocument();
+ expect(screen.getByText(/Cloudflare uses your IP address/)).toBeInTheDocument();
+ // The browser's storage is named, not hidden behind "no cookies".
+ expect(screen.getByText(/your plot\s+votes, a release number/)).toBeInTheDocument();
});
// The data assistant's section, each claim pinned with the qualifier that
// makes it true: experimental and open to everyone but pausable, training
- // excluded only under Vertex AI's terms, telemetry pseudonymous (not
- // anonymous), the data file unticked by default, and a case row that has no
- // timer while its files do.
+ // excluded under Google Cloud's terms, the ids pseudonymous (not anonymous),
+ // the data file unticked by default, and each legal basis with its purpose.
it('describes the experimental data assistant without overclaiming', () => {
render();
expect(
- screen.getByText(
- /open to everyone as an\s+experimental feature, and the operator may pause it/
- )
+ screen.getByText(/open to everyone, experimental and without warranty/)
).toBeInTheDocument();
+ expect(screen.getByText(/nobody checks them by hand/)).toBeInTheDocument();
+ expect(screen.getByText(/the operator may pause it at any time/)).toBeInTheDocument();
expect(
- screen.getByText(/served through Google Cloud Vertex AI in the\s+EU/)
+ screen.getByText(
+ /served through Google Cloud Vertex AI in the EU under Google Cloud.s data processing terms/
+ )
).toBeInTheDocument();
+ expect(screen.getByText(/your prompts are not used for training/)).toBeInTheDocument();
expect(
- screen.getByText(/processed under Google Cloud.s data\s+processing terms/)
+ screen.getByText(/don.t paste personal data you are not allowed to share/)
).toBeInTheDocument();
expect(
- screen.getByText(/don.t paste personal data you are not allowed to\s+share/)
+ screen.getByText(/with no database, and is cleared after an hour idle/)
).toBeInTheDocument();
- expect(screen.getByText(/there is no session database/)).toBeInTheDocument();
- expect(screen.getByText(/pseudonymous, not\s+anonymous/)).toBeInTheDocument();
- // The language is about the user: named, and only as a code with rare ones folded into "other".
+ // The renderer's answer cache has no idle timer of its own.
+ expect(screen.getByText(/keeps its last 16 results in memory/)).toBeInTheDocument();
+ expect(screen.getByText(/never your text, data, column or file names/)).toBeInTheDocument();
+ // The language is about the user: only as a code, with rare ones folded into "other".
+ expect(screen.getByText(/language code \(rare ones as "other"\)/)).toBeInTheDocument();
+ // A public user's id is a salted random tab id that dies with the instance; only admins get the HMAC.
expect(
screen.getByText(
- /language as a code \(one of a dozen common languages, any\s+other only as "other"\)/
+ /salted with a value that lives only as long as the server instance, in memory, never written down/
)
).toBeInTheDocument();
- expect(screen.getByText(/up to 180 days/)).toBeInTheDocument();
- // A public user's id is a salted random tab id that dies with the instance; only admins get the HMAC.
+ expect(screen.getByText(/keyed hash \(HMAC\)/)).toBeInTheDocument();
+ // The 30-day server logs could only be joined by timestamp: pseudonymous, not anonymous.
expect(
- screen.getByText(/salted with a value that lives only as\s+long as the server instance/)
+ screen.getByText(/only timestamps could match them to the 30-day server logs/)
+ ).toBeInTheDocument();
+ expect(screen.getByText(/so they are pseudonymous/)).toBeInTheDocument();
+ // Nothing is kept without feedback; the box starts unticked, and leaving it so drops
+ // data.csv only, while the profile sample stays.
+ expect(screen.getByText(/kept only when you send feedback/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/it starts unticked, which leaves out that file only/)
+ ).toBeInTheDocument();
+ expect(screen.getByText(/first 5 rows/)).toBeInTheDocument();
+ // Each legal basis with what it covers; consent given at the send and withdrawable.
+ expect(
+ screen.getByRole('row', { name: /^Art\. 6\(1\)\(f\)\s*legitimate interest/ })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('row', { name: /^Art\. 6\(1\)\(b\)\s*your request: making the plot/ })
).toBeInTheDocument();
- expect(screen.getByText(/keyed hash \(HMAC\)/)).toBeInTheDocument();
- expect(screen.getByText(/salted, in memory only, never written down/)).toBeInTheDocument();
- // The box starts unticked, and leaving it so drops data.csv only; the profile sample stays.
- expect(screen.getByText(/it starts\s+unticked/)).toBeInTheDocument();
- expect(screen.getByText(/leaving it unticked leaves out that file only/)).toBeInTheDocument();
- expect(screen.getByText(/first 5 rows as a sample/)).toBeInTheDocument();
- // The renderer's answer cache has no idle timer of its own.
- expect(screen.getByText(/keeps its last 16 results/)).toBeInTheDocument();
- expect(screen.getByText(/nothing deletes it on a timer/)).toBeInTheDocument();
- expect(screen.getByText(/Art\. 6\(1\)\(b\) GDPR/)).toBeInTheDocument();
- expect(screen.getByText(/Art\. 6\(1\)\(f\) GDPR/)).toBeInTheDocument();
- // A feedback case rests on consent, given at the send and withdrawable.
expect(
- screen.getByText(/a feedback case rests on your consent \(Art\. 6\(1\)\(a\)\s+GDPR\)/)
+ screen.getByRole('row', {
+ name: /^Art\. 6\(1\)\(a\)\s*your consent to a feedback case, given when you send it and withdrawable at any time by asking$/,
+ })
).toBeInTheDocument();
- expect(screen.getByText(/which you can withdraw at any time by asking/)).toBeInTheDocument();
- // The two 30-day logs could only be joined by timestamp; the salted ids map to nothing else.
- expect(screen.getByText(/could only be lined up by their\s+timestamps/)).toBeInTheDocument();
// "No cookies" holds for visitors; admins get Cloudflare Access's login cookie.
expect(screen.getByText(/no cookies for visitors/)).toBeInTheDocument();
expect(
@@ -160,22 +192,24 @@ describe('LegalPage', () => {
// Scope: the assistant's processing touches only the people who use it; the chat links here.
const heading = screen.getByText('only if you use the data assistant (experimental)');
expect(heading).toHaveAttribute('id', 'assistant');
- // Use, warranty, the code's licence with the Highcharts exception, and the transfers.
- expect(screen.getByText(/comes with no\s+warranty/)).toBeInTheDocument();
- expect(screen.getByText(/nobody checks them by hand/)).toBeInTheDocument();
- expect(screen.getByText(/yours to\s+use, as is/)).toBeInTheDocument();
expect(
- screen.getByText(
- /Highcharts is free for non-commercial use, and a\s+commercial use needs a licence from Highsoft/
- )
+ screen.getByText(/if you only browse the catalogue, none of it touches you/)
).toBeInTheDocument();
- expect(screen.getByText(/transfers\s+outside Switzerland and the EU/)).toBeInTheDocument();
- expect(screen.getByText(/if you\s+only browse the catalogue, none of it/)).toBeInTheDocument();
expect(
- screen.getByText(/Claude models by Anthropic \(EU\) — only when you use the\s+data assistant/)
+ screen.getByRole('row', {
+ name: /^data assistant\s*Claude by Anthropic via Google Cloud Vertex AI \(EU\), if you use it$/,
+ })
).toBeInTheDocument();
- // Browsing the catalogue still involves no model; only the assistant does.
- expect(screen.getByText(/never sees a visitor.s request/)).toBeInTheDocument();
+ // The code's licence with the Highcharts exception, and the transfers.
+ expect(screen.getByText(/yours to use, as is/)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Highcharts is free for non-commercial use, and a commercial use needs a licence from Highsoft/
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByText(/transfers outside Switzerland and the EU/)).toBeInTheDocument();
+ // Browsing the catalogue involves no model, and nothing sent trains one.
+ expect(screen.getByText(/browsing never reaches a model/)).toBeInTheDocument();
});
it('renders the technology stack', () => {
@@ -184,6 +218,53 @@ describe('LegalPage', () => {
expect(screen.getByRole('link', { name: 'React' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /FastAPI/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /PostgreSQL/ })).toBeInTheDocument();
+ // Eight grouped rows, each label a row header.
+ for (const group of [
+ 'site',
+ 'api',
+ 'catalogue pipeline',
+ 'data assistant',
+ 'google cloud',
+ 'edge & analytics',
+ 'reviews',
+ 'editor, typography',
+ ]) {
+ expect(screen.getAllByRole('rowheader', { name: group }).length).toBeGreaterThan(0);
+ }
+ expect(
+ screen.getByRole('row', {
+ name: /^data assistant\s*Google ADK, Claude Haiku 5\.5 via Vertex AI \(EU\)/,
+ })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/15\s+plotting libraries in Python, R, Julia and JavaScript/)
+ ).toBeInTheDocument();
+ // Only the services the repository pins to europe-west4 carry the region.
+ expect(
+ screen.getByText(
+ /Cloud Build and Artifact Registry in the\s+Netherlands \(europe-west4\), plus Secret Manager and Cloud Monitoring/
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('opens a fresh load of /legal#assistant at the assistant section', () => {
+ // The route is lazy, so the browser's own jump to the fragment finds nothing; the page scrolls itself.
+ const original = Element.prototype.scrollIntoView;
+ const scrollIntoView = vi.fn();
+ Element.prototype.scrollIntoView = scrollIntoView;
+ try {
+ rtlRender(
+
+
+
+
+
+ );
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
+ expect(scrollIntoView.mock.contexts[0]).toHaveAttribute('id', 'assistant');
+ } finally {
+ Element.prototype.scrollIntoView = original;
+ }
});
// The repository vendors JetBrains Mono (agents/anyplot/render/fonts/) under
@@ -208,10 +289,31 @@ describe('LegalPage', () => {
).toBeInTheDocument();
});
- it('renders hosting costs', () => {
+ it('renders the running costs of one named month, with what they leave out', () => {
render();
- expect(screen.getByText('~$34/month')).toBeInTheDocument();
+ expect(screen.getByText('monthly running costs (October 2026)')).toBeInTheDocument();
+ const costs: [string, string][] = [
+ ['hosting: Cloud Run \\(site, api, data assistant, renderer\\)', '~\\$10'],
+ ['database: Cloud SQL', '~\\$25'],
+ ['data assistant model: Claude Haiku via Vertex AI', '~\\$15'],
+ ['storage, logs and builds', '~\\$1'],
+ ['domain \\(\\.ai\\)', '~\\$8'],
+ ['total', '~\\$60/month'],
+ ];
+ for (const [item, cost] of costs) {
+ expect(
+ screen.getByRole('row', { name: new RegExp(`^${item}\\s*${cost}$`) })
+ ).toBeInTheDocument();
+ }
+ // Paid after discounts, so the note says which ones, and that the model cost moves with use.
+ expect(
+ screen.getByText(/after three-year commitments and promotional credits/)
+ ).toBeInTheDocument();
+ expect(screen.getByText(/the assistant.s model cost\s+grows with use/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/development tools and subscriptions are not included/)
+ ).toBeInTheDocument();
});
it('renders other projects links without noreferrer', () => {
diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx
index a92dbe713f..af99995664 100644
--- a/app/src/pages/LegalPage.tsx
+++ b/app/src/pages/LegalPage.tsx
@@ -1,4 +1,6 @@
-import { useEffect } from 'react';
+import { type ReactNode, useEffect } from 'react';
+
+import { useLocation } from 'react-router-dom';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
@@ -20,23 +22,71 @@ import {
textStyle,
} from 'src/theme';
-const firstColStyle = {
- '& .MuiTableCell-root:first-of-type': {
- fontWeight: 500,
- color: 'var(--ink-soft)',
- width: '25%',
- },
-};
-
const AVATAR_URL = 'https://storage.googleapis.com/anyplot-static/images/markusneusinger.webp';
+/** Where a linked section lands: below the sticky masthead (42 to 44 px), with a little air. */
+const ANCHOR_SX = { scrollMarginTop: '56px' } as const;
+
+/** An external link in the page's prose style, opened in a new tab. */
+function Ext({ href, children }: { href: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** A two-column fact table: a short label as the row header, then the fact. */
+function FactTable({
+ rows,
+ labelWidth = '25%',
+}: {
+ rows: [string, ReactNode][];
+ labelWidth?: string;
+}) {
+ return (
+
, so the value
is a first of its type too.
+ '& .MuiTableCell-root:first-child': {
+ fontWeight: 500,
+ color: 'var(--ink-soft)',
+ width: labelWidth,
+ },
+ }}
+ >
+
+ {rows.map(([label, fact]) => (
+
+
+ {label}
+
+ {fact}
+
+ ))}
+
+
+ );
+}
+
export function LegalPage() {
const { trackPageview, trackEvent } = useAnalytics();
+ const { hash } = useLocation();
useEffect(() => {
trackPageview('/legal');
}, [trackPageview]);
+ // The route loads lazily, after the browser has looked for the fragment and not found it, so a
+ // fresh load of /legal#assistant (the chat's notice opens one in a new tab) would stay at the top.
+ // Scroll to it once the page is there; ANCHOR_SX keeps it clear of the sticky masthead.
+ useEffect(() => {
+ if (!hash) return;
+ document.getElementById(hash.slice(1))?.scrollIntoView?.();
+ }, [hash]);
+
const operatorContactBlock = (
<>
operator
@@ -123,11 +173,9 @@ export function LegalPage() {
<>
disclaimer
- this is a personal portfolio project showcasing data visualization examples, generated and
- maintained through ai-powered workflows. all code examples are meant for inspiration and
- learning — take them as a starting point, adapt them to your data and requirements, or use
- ai tools to customize them for your specific needs. code is provided "as is" under
- the MIT License and should be reviewed before production use.
+ a personal portfolio project: plot examples generated and maintained by ai workflows, meant
+ as a starting point for your own data. the code is provided "as is" under the MIT
+ License; review it before production use.
>
);
@@ -150,7 +198,7 @@ export function LegalPage() {
{/* Legal Notice */}
-
+ legal notice} />
@@ -181,467 +229,271 @@ export function LegalPage() {
{/* Privacy Policy */}
-
+ privacy policy} />
- data controller
+ controller
- Markus Neusinger (see legal notice above). the ground for the site itself is an
- operator's legitimate interest in protecting the site and knowing how often it is
- read; the data assistant below, which only its users meet, names its own grounds.
- swiss data protection law applies, and the GDPR on top for visitors from the EU.
+ Markus Neusinger (see above). swiss data protection law applies, and the GDPR for
+ visitors from the EU.
- what we collect
-
- anonymized analytics. the counting is done by{' '}
-
- Plausible Analytics
-
- . 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{' '}
-
- fully public
- {' '}
- — see exactly what we see.
-
-
- server logs. technical server logs including IP addresses, request
- URLs, and user agents are kept for 30 days in{' '}
-
- Google Cloud Logging
- {' '}
- in the Netherlands (europe-west4), for security and debugging purposes. entries
- written before the move on 2 November 2026 stay in Google's global log storage
- until their 30 days are over.
-
-
- 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, and a random id that ties your own submissions
- together. to limit spam it also keeps a hash of your IP address, salted with a value
- that lives only in the server's memory, and clears it after ten minutes; entries
- sent before November 2026 still carry an older, unsalted hash until it is cleared.
- 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 is processed
+
+
+ Plausible
+ {' '}
+ counts page views, searches, clicks, copies, downloads and Core Web Vitals, only
+ in aggregate, on a{' '}
+
+ public dashboard
+
+ . its script is served through this domain: no cookies, no cross-site id, no IP
+ address kept.
+ >,
+ ],
+ [
+ 'server logs',
+ 'IP address, URL and user-agent of each request, for security and debugging.',
+ ],
+ [
+ 'edge',
+ 'Cloudflare uses your IP address to rate-limit, filter bots and absorb DDoS attacks.',
+ ],
+ [
+ 'feedback',
+ 'message, reaction, optional contact, page, your window size, user-agent and a random id that ties your own submissions together, read only by the operator. a salted hash of your IP address limits spam.',
+ ],
+ [
+ 'github',
+ 'suggest a plot type there and your public username may be credited in its spec.',
+ ],
+ ]}
+ />
-
- only if you use the data assistant (experimental)
-
-
- everything in this part applies only to people who use the data assistant. if you only
- browse the catalogue, none of it touches you. the assistant adapts a catalogue plot to
- data you bring ("use with my data"). it is open to everyone as an
- experimental feature, and the operator may pause it at any time. this is what it does
- with what you give it.
-
+ where it runs
+
- what the model sees. what you paste or drop and the messages you
- write go to Claude models by Anthropic, served through Google Cloud Vertex AI in the
- EU, to make the plot you asked for. they are processed under Google Cloud's data
- processing terms, and under Google Cloud's terms for Vertex AI your prompts are
- not used to train the models. don't paste personal data you are not allowed to
- share.
-
-
- sessions. a session — your data, the conversation and the plots made
- from it — is held in the server's memory only. it is cleared after an hour
- without activity or when the server instance stops; there is no session database. the
- separate render service also keeps its last 16 results (converted data, plot images)
- in memory, until newer renders push them out or its instance stops.
-
-
- usage telemetry. to see how the assistant is used and where it fails,
- it writes content-free lines: event types, data formats, size buckets, the plot type
- and library, the plot's language as a code (one of a dozen common languages, any
- other only as "other"), attempts, review defect ids, statuses, latencies and
- token counts. a line never holds your text, your data, your column names or your file
- names. the lines of one session share a salted hash of the session, whose salt lives
- only as long as the server instance, and they carry no user id — pseudonymous, not
- anonymous. they are kept for up to 180 days in a Google Cloud Logging bucket in the
- Netherlands (europe-west4) and read through BigQuery.
-
-
- attribution and rate limits. the assistant's own log lines,
- which debug it and enforce its limits, carry no content. in place of who you are they
- carry a random id your browser tab makes up, salted with a value that lives only as
- long as the server instance, so nothing ties it to you once that instance stops (for
- the site's admins, a keyed hash (HMAC) of their identity). the limits per network
- count your IP address the same way: salted, in memory only, never written down. the
- log lines sit in the server logs above, for 30 days. the API's request log (your
- IP address, 30 days) and these lines (salted ids, 30 days) could only be lined up by
- their timestamps, and the salted ids map to nothing else.
-
-
- feedback cases. only when you send feedback on a result or on the
- chat does the assistant store a case: the conversation, the profile of your data
- (column types and the first 5 rows as a sample), the plot's code and images if
- one was made and, only if you tick the "# include data.csv" box — it starts
- unticked — the whole data file. leaving it unticked leaves out that file only: what
- the conversation, the sample, the code and the images show of your data stays in the
- case. the form says so where you send it. the files go to a private bucket in the
- Netherlands (europe-west4) and are deleted after 180 days. a row with your reaction,
- your short message, a hash of your tab's random id and the technical details of
- the run (plot type, library, model) stays for the triage, and nothing deletes it on a
- timer — ask and yours is gone.
-
-
- use and warranty. the assistant is experimental and comes with no
- warranty: plots, code and answers can be wrong, and nobody checks them by hand, so
- check them before you rely on them. use it only with data you may share; don't
- use it to make unlawful content or to send it automated requests. daily limits apply
- per browser tab and per network, a misuse can pause you for the day, and the operator
- may pause or end the assistant at any time.
-
-
- the code you get. the code the assistant writes for you is yours to
- use, as is, like the catalogue's code under the MIT License. the plotting
- libraries keep their own licences: Highcharts is free for non-commercial use, and a
- commercial use needs a licence from Highsoft.
-
-
- transfers. Google Cloud and Cloudflare process what you send under
- their data processing terms, including the safeguards those terms set for transfers
- outside Switzerland and the EU.
-
-
- legal basis. making the plot you asked for is the performance of your
- request (Art. 6(1)(b) GDPR); the telemetry and the rate limits rest on legitimate
- interest (Art. 6(1)(f) GDPR); a feedback case rests on your consent (Art. 6(1)(a)
- GDPR), given when you send it, which you can withdraw at any time by asking.
+ Google Cloud and Cloudflare process data under their data processing terms, including
+ their safeguards for transfers outside Switzerland and the EU.
- what we do not collect
+ how long it stays
+
+
+ legal bases (GDPR)
+
+
+ what we do not do
- • no user accounts or personal profiles
+ • no cookies for visitors; the browser's storage keeps UI settings, your plot
+ votes, a release number and the random ids named here. admins sign in through
+ Cloudflare Access, which sets its own login cookie
- • no name or email unless you type one — the optional contact field in the feedback
- widget is the only place that asks
-
- • no cookies for visitors (localStorage holds UI preferences — theme, image size — a
- cached release number, and, once you use the feedback widget, the random id above;
- sessionStorage holds the data assistant's random tab id). the site's admins
- sign in through Cloudflare Access, which sets its own login cookie
- • no ai training: nothing you send is used to train ai models.
- when you browse the catalogue, the ai in this project has already done its part: it
- writes the catalogue at build time and never sees a visitor's request. only the
- data assistant above sends what you give it to a model, under the terms described
- there
+ • no accounts, and no name or email unless you type one
+ • no ai training on what you send. the catalogue is written at
+ build time, so browsing never reaches a model
-
- contributors. if you suggest a plot type via GitHub, your GitHub
- username may be credited in the specification metadata. this is public information
- from your GitHub profile.
+
+ only if you use the data assistant (experimental)
-
- hosting & third parties
- the site, its database and its images run in the EU (Netherlands, europe-west4);
- Cloudflare's network is global, Plausible is a service of its own on its own EU
- servers, and the data assistant's models — used only when you use the assistant —
- are called in Vertex AI's EU region:
+ if you only browse the catalogue, none of it touches you.
-
-
-
- hosting
- Google Cloud Run (Netherlands)
-
-
- database
- Google Cloud SQL (Netherlands)
-
-
- storage
- Google Cloud Storage (Netherlands)
-
-
- cdn / ddos
- Cloudflare (global, EU data centers for EU visitors)
-
-
- analytics
- Plausible Analytics (EU, proxied)
-
-
- ai assistant
-
- Google Cloud Vertex AI, Claude models by Anthropic (EU) — only when you use the
- data assistant
-
-
-
-
+
+ kept only when you send feedback: the conversation, a profile of your data with
+ its first 5 rows, the plot's code and images, and the whole data file only
+ if you tick{' '}
+
+ "# include data.csv"
+
+ . it starts unticked, which leaves out that file only. files go to a private
+ bucket in the Netherlands; a triage row keeps your reaction, message, a hash of
+ your tab id and the run's details.
+ >,
+ ],
+ [
+ 'limits',
+ 'use it only with data you may share, never for unlawful content or automated requests. daily limits apply per tab and per network; a misuse can pause you for the day.',
+ ],
+ [
+ 'your code',
+ "yours to use, as is, like the catalogue's code under the MIT License. Highcharts is free for non-commercial use, and a commercial use needs a licence from Highsoft.",
+ ],
+ ]}
+ />
your rights
- 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, an
- assistant feedback case or a log line; a few lines to{' '}
+ you can ask what is stored about you and have it corrected, erased or restricted,
+ object to the processing on grounds relating to your particular situation, and
+ complain to a supervisory authority. a few lines to{' '}
admin@anyplot.ai
{' '}
- are enough, also to have an assistant feedback case deleted early, its files and its
- row.
+ are enough, also to have a feedback case deleted early.
{/* Transparency */}
-
+ transparency} />
-
- this project is open source and committed to full transparency about how it works and
- what it costs.
-
+ open source, down to what it costs to run.technology stack
-
+ monthly running costs (October 2026)
+ ~$60/month],
+ ]}
+ />
- direct hosting costs only. subscriptions (GitHub Pro, Plausible, Claude MAX, PyCharm,
- etc.) are shared across projects. all costs are currently covered privately.
+ after three-year commitments and promotional credits; the assistant's model cost
+ grows with use. development tools and subscriptions are not included. all costs are
+ covered privately.
diff --git a/changelog.d/legal-page-short.md b/changelog.d/legal-page-short.md
new file mode 100644
index 0000000000..529861777e
--- /dev/null
+++ b/changelog.d/legal-page-short.md
@@ -0,0 +1,24 @@
+### Changed
+
+- **The legal page is shorter and states only the current state.** Its visible text drops from 1,805 to 1,072
+ words (the privacy policy from 1,546 to 775): what is processed, where it runs, how long each store keeps it, the
+ three legal bases and the data assistant's terms now sit in short fact tables with row headers, each fact said
+ once. The notes on log entries written before the move to europe-west4 and on older unsalted feedback hashes are
+ gone; both conditions ended on 2026-11-02. The feedback IP hash no longer reads as a flat ten minutes: it is
+ cleared by the next feedback after its window, and its salt dies with the server instance. The crawler version
+ (`/seo-proxy/legal`, 450 words, 370 before) drops the same history and now carries every fact the page states:
+ the stored feedback fields, Cloudflare's use of the IP address, all three legal bases, your rights, the telemetry
+ retention, the no-training line and the running costs (#12538).
+- **The transparency section lists the whole stack and what running the site costs.** Eight grouped rows (site,
+ api, catalogue pipeline, data assistant, Google Cloud, edge and analytics, reviews, editor and typography) replace
+ the old list, which named neither the data assistant nor most of the Google Cloud services. The cost table shows
+ October 2026 as paid after commitments and credits, about $60 a month with the assistant's model, in place of a
+ $34 estimate of hosting alone (#12538).
+
+### Fixed
+
+- **A link to a section of the legal page lands on its heading.** The route loads lazily, so a fresh load of
+ `/legal#assistant`, which the chat's notice opens in a new tab, stayed at the top of the page; it now scrolls to
+ the section once the page renders, below the sticky masthead. The code and docs that still promised a migration
+ for the old unsalted feedback IP hashes now say they were cleared once by hand on 2026-11-02 after a snapshot
+ (#12538).
diff --git a/core/database/models.py b/core/database/models.py
index 530c60fbe0..5a1b01e878 100644
--- a/core/database/models.py
+++ b/core/database/models.py
@@ -262,8 +262,8 @@ class Feedback(Base):
viewport: Mapped[str | None] = mapped_column(String(20), nullable=True)
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# "hmac:" + a per-process salted HMAC of the client IP (api/routers/feedback.py `_hash_ip`), only for the
- # rate limit and the duplicate check, cleared once their 10-minute window has passed; rows from before
- # 2026-11-02 still hold an unsalted SHA-256 until a migration clears them.
+ # rate limit and the duplicate check, cleared once their 10-minute window has passed. The unsalted SHA-256
+ # hashes of older rows were cleared once by hand on 2026-11-02 after a snapshot; no migration is needed.
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Triage state — see FEEDBACK_STATUSES above. Updated from /debug.
diff --git a/core/database/repositories.py b/core/database/repositories.py
index 8e616620ca..4dbb657482 100644
--- a/core/database/repositories.py
+++ b/core/database/repositories.py
@@ -439,8 +439,9 @@ async def clear_ip_hashes(self, prefix: str, before: datetime) -> int:
The feedback router stores a per-process salted hash (`api/routers/feedback.py`
`_hash_ip`) only for its rate-limit and duplicate windows and clears it here once
- they have passed. `prefix` keeps the statement to the hashes of that scheme: the
- unsalted hashes of older rows are left for a reviewed migration.
+ they have passed. `prefix` keeps the statement to the hashes of that scheme; the
+ unsalted hashes of older rows were cleared once by hand on 2026-11-02 after a
+ snapshot, so no migration is needed.
`before` must be tz-naive UTC, like `feedback.created_at`."""
result = await self.session.execute(
update(Feedback)
diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md
index 0c14d853e4..7b1edfe2d5 100644
--- a/docs/concepts/agent-network.md
+++ b/docs/concepts/agent-network.md
@@ -692,7 +692,7 @@ Defaults apply until the owner decides otherwise.
- Feedback-case retention and data inclusion: 180 days in the private bucket; `include_data` on by default for admins and off by default with explicit consent for real users.
- anyplot-api on two instances for the public lane (launch package of 2026-11-02): not yet, `_MAX_INSTANCES` stays 1. The API's in-memory response cache (`api/cache.py`, refreshed after 1 h) is cleared by one `POST /debug/cache/invalidate` per sync, which reaches one instance only, so a second instance would serve the catalogue as it was before a sync for up to an hour; and every scale-out is a cold start a live request pays for (the measurement in `api/cloudbuild.yaml`). The public lane's own stream cap keeps the catalogue's share on one instance instead. Nothing else relies on one instance: the BFF keeps no session state, the identity salting lives in the agents service, and MCP is stateless.
- Trust in `CF-Connecting-IP` for the per-IP caps: a caller who bypasses Cloudflare for the direct `run.app` URL can forge it, and so pick a fresh network bucket per request, until the origin gate is armed (`ORIGIN_SECRET`, `api/origin_gate.py`). Arming the gate before the public launch closes it; the global day budget bounds the damage meanwhile.
-- The unsalted `ip_hash` of feedback rows from before 2026-11-02: the router stores a per-process salted hash since then and clears it after its 10-minute window, but older rows keep a plain SHA-256 of the client address, which trying every IPv4 address undoes. Clearing them is a data-rewriting migration that needs a snapshot and the owner's OK.
+- The unsalted `ip_hash` of feedback rows from before 2026-11-02: closed. The router stores a per-process salted hash since then and clears it after its 10-minute window; the older rows' plain SHA-256 of the client address, which trying every IPv4 address undoes, was cleared once by hand on 2026-11-02 after a snapshot (all 40 rows), so no migration is needed.
- The sandbox's private `/tmp` and memory: spike S2 counts each sandbox's `/tmp` tmpfs (about 1.9 GiB, no flag to shrink it) as instance memory, which on paper puts even one sandbox (1 GiB address space, 1.9 GiB `/tmp`, the 512 MiB volume) above the 3 GiB budget of a 4 GiB instance. Measured on 2026-10-10 through the deployed renderer (a throwaway service, 2 vCPU, 4 GiB, one sandbox at a time): code that writes 40 MiB files into `/tmp` until it is full ends after 4.6 s with `OSError: [Errno 28] No space left on device`, the instance stays up with 3,328 MiB available afterwards (3,451 before), and the memory watchdog never has to fire. Decided: a 4 GiB instance with one sandbox at a time, without `RENDERER_BIND_TMP` (which stays available, untested) and without an 8 GiB instance; the validator (no file writes in user code), the serial slot, the `MemAvailable` floor before a render (1,024 MiB) and the watchdog's floor during one (512 MiB, reason `memory`) stay as the controls. Re-measure before a second render slot.
- How a developer's `adk web` authenticates to the deployed renderer: `AGENT_RENDER_TOKEN` from `gcloud auth print-identity-token` is the path spike S proved against Cloud Run IAM; the token minted from a user's Application Default Credentials carries the ADC OAuth client id as `aud`, and whether Cloud Run IAM accepts it is not yet verified. Either way, the token's client id joins `RENDERER_AUDIENCES` (both client ids are in the deployed service's `_AUDIENCES`) and the owner's email `RENDERER_ALLOWED_CALLERS`. Checked on 2026-10-11 against the deployed renderer: the gcloud CLI token renders (the harness and the local stack use it); the ADC token path is still unverified.
- Cold starts of the renderer: with `--min-instances=0`, the first render after an idle period meets a cold instance, and spike S saw the front end answer 500, then 429, and a 200 only about 17 s after the first request. The `remote` backend retries such answers after 1, 3, 8 and 15 s within 30 s. The deployed renderer runs with `--min-instances=0` since 2026-10-11, and the harness rendered through it that day without a retry failure, but no render has been timed against a deliberately cold instance yet; that measurement decides whether the retries suffice or the renderer runs with `_MIN_INSTANCES=1` (the billing commitments probably absorb its cost).
diff --git a/docs/reference/database.md b/docs/reference/database.md
index b4e7d2c6e1..d5d85aacea 100644
--- a/docs/reference/database.md
+++ b/docs/reference/database.md
@@ -175,8 +175,8 @@ CREATE TABLE feedback
viewport VARCHAR(20),
session_id VARCHAR(64),
ip_hash VARCHAR(64), -- "hmac:" + a per-process salted HMAC of the client IP, for the rate limit;
- -- cleared once its 10-minute window has passed (rows from before
- -- 2026-11-02 still hold an unsalted SHA-256 until a migration clears them)
+ -- cleared once its 10-minute window has passed (the unsalted SHA-256
+ -- of older rows was cleared once by hand on 2026-11-02 after a snapshot)
status VARCHAR(20) NOT NULL, -- new, in_progress, done, wont_solve
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py
index ed50218a59..39f753f959 100644
--- a/tests/integration/test_repositories.py
+++ b/tests/integration/test_repositories.py
@@ -452,7 +452,10 @@ async def test_count_recent_by_ip_messages_only(self, test_session):
assert await repo.count_recent_by_ip("aaa", since, messages_only=True) == 1
async def test_clear_ip_hashes_clears_only_the_salted_ones_past_the_window(self, test_session):
- """The router's salted hashes outlive no window; the unsalted ones of older rows are left for a migration."""
+ """The router's salted hashes outlive no window; a hash of any other scheme is not this statement's to clear.
+
+ Production holds no unsalted hashes since the one-off clean-up of 2026-11-02; the legacy row here
+ pins that the statement stays scoped to its own prefix."""
from datetime import datetime, timedelta, timezone
repo = FeedbackRepository(test_session)
diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py
index 96af8b793c..a723298931 100644
--- a/tests/unit/api/test_routers.py
+++ b/tests/unit/api/test_routers.py
@@ -1180,10 +1180,28 @@ def test_seo_legal_names_operator_and_privacy_facts(self, client: TestClient) ->
# 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
- # The server logs sit in the EU since 2026-11-02 (the `_Default` sink's new bucket).
+ assert "kept until you ask to delete it" in response.text
+ # The server logs sit in the EU (the `_Default` sink's europe-west4 bucket).
assert "kept 30 days in Google Cloud Logging in the Netherlands (europe-west4)" in response.text
- assert "written before the move on 2 November 2026" in response.text
+ # The page states the current state only: no notes on what older entries still carry.
+ assert "before the move" not in response.text
+ assert "unsalted" not in response.text
+ # The site-wide facts the human page states once each, the stored feedback fields included.
+ for fact in (
+ "Running costs about $60 a month (October 2026).",
+ "a random id that ties your own submissions together",
+ "the optional contact field",
+ "legitimate interest (Art. 6(1)(f) GDPR)",
+ "the Cloudflare Access login cookie",
+ "a complaint to a supervisory authority",
+ "transfers outside Switzerland and the EU",
+ "cleared by the next feedback after ten minutes, and its salt dies with the server instance",
+ "uses your IP address to rate-limit, filter bots and absorb DDoS attacks",
+ "no name or email unless you type one",
+ "may credit your public username",
+ "browsing the catalogue never reaches a model",
+ ):
+ assert fact in response.text, fact
def test_seo_legal_mirrors_the_data_assistant_section(self, client: TestClient) -> None:
"""The crawler body carries the assistant's facts the human page states (LegalPage.tsx `#assistant`)."""
@@ -1198,9 +1216,12 @@ def test_seo_legal_mirrors_the_data_assistant_section(self, client: TestClient)
"it starts unticked",
"180 days",
"a licence from Highsoft",
- "transfers outside Switzerland and the EU",
- "cleared after ten minutes",
"consent (Art. 6(1)(a) GDPR)",
+ "your request (Art. 6(1)(b) GDPR)",
+ "Telemetry: up to 180 days",
+ "cleared after an hour idle",
+ "it is not used for training",
+ "The triage row stays until you ask to delete it",
):
assert fact in response.text, fact