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.

" + "" '

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.

" + "" "

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 - - - - editor - - - JetBrains PyCharm - - - - - frontend - - - React - {' '} - 19,{' '} - - Vite - - ,{' '} - - MUI - {' '} - 9,{' '} - - TypeScript - - - - - backend - - - Python - {' '} - 3.14,{' '} - - FastAPI - - ,{' '} - - SQLAlchemy - - - - - database - - - PostgreSQL - {' '} - 18 - - - - hosting - - - Google Cloud Run - {' '} - (Netherlands) - - - - cdn / ddos - - - Cloudflare - - - - - storage - - - Google Cloud Storage - - - - - analytics - - - Plausible - {' '} - (privacy-friendly, no cookies,{' '} - - public dashboard - - ) - - - - code generation - - - Anthropic Claude - {' '} - (code generation & review),{' '} - - GitHub Copilot - {' '} - (PR reviews) - - - - typography - - - MonoLisa - {' '} - by Marcus Sterz -
- - JetBrains Mono - {' '} - 2.304 ( - - SIL Open Font License 1.1 - - ) — for the attribution strip under plots created from your own data -
-
-
-
+ + React 19,{' '} + Vite, MUI 9 + with MUI X,{' '} + TypeScript + , + ], + [ + 'api', + <> + Python 3.14,{' '} + FastAPI,{' '} + SQLAlchemy,{' '} + PostgreSQL 18 + , + ], + [ + 'catalogue pipeline', + <> + GitHub Actions with Claude + (Claude Code); 15 + plotting libraries in Python, R, Julia and JavaScript + , + ], + [ + 'data assistant', + <> + Google ADK,{' '} + Claude Haiku 5.5 via{' '} + Vertex AI (EU), plots + rendered in Cloud Run sandboxes (gVisor) + , + ], + [ + 'google cloud', + <> + Cloud Run, Cloud SQL, Cloud + Storage, Cloud Logging, BigQuery, Cloud Build and Artifact Registry in the + Netherlands (europe-west4), plus Secret Manager and Cloud Monitoring + , + ], + [ + 'edge & analytics', + <> + Cloudflare,{' '} + Plausible (no cookies,{' '} + public dashboard) + , + ], + [ + 'reviews', + <> + Gemini via Vertex + AI for second opinions,{' '} + GitHub Copilot for pull + requests + , + ], + [ + 'editor, typography', + <> + JetBrains PyCharm;{' '} + MonoLisa by Marcus Sterz;{' '} + JetBrains Mono{' '} + 2.304 (SIL Open Font License 1.1) + for the attribution strip under plots created from your own data + , + ], + ]} + /> source code @@ -655,40 +507,24 @@ export function LegalPage() { Open Font License 1.1. - monthly hosting costs (approximate) - - - - Cloud Run - ~$15 - - - Cloud SQL - ~$10 - - - Cloud Storage - ~$1 - - - Domain (.ai) - ~$8 - - - - total - - - ~$34/month - - - -
+ 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