diff --git a/CHANGELOG.md b/CHANGELOG.md index c02a7469fe..51da0287c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,22 @@ aggregate instead: an italic *Catalog* line at the end of the version section an ## [Unreleased] +### Fixed + +- **The masthead had been advertising v1.0 since the security-headers rollout** — the version next + to `~/anyplot.ai` comes from the GitHub releases API, and the CSP shipped in the 2026-07-16 audit + never listed `api.github.com` under `connect-src`. Every browser blocked that request, the hook + returned `null`, and the masthead fell back to a hardcoded `'v1.0'` — so the v3.0.0 and v3.1.0 + releases both went out to a site claiming to be v1.0. Returning visitors kept seeing a plausible + number from their `localStorage` cache, which is why this survived two releases. `api.github.com` + is now allowed in `connect-src` (#10485). +- **The version fallback no longer invents a number** — the fallback is on screen for every cold + load while the API call is in flight, and stays there whenever GitHub is unreachable or + rate-limits the visitor's IP (60 requests/hour, unauthenticated). It now renders the build-time + project version, injected by Vite from the `[project]` version in `pyproject.toml` — the same + field the release flow bumps — instead of a literal that nobody would think to update. A test + asserts the injected value matches `pyproject.toml`, so the two cannot drift (#10485). + ## [3.1.0] — 2026-08-19 — Legible to machines anyplot 3.1 makes the catalogue readable by machines. An assistant asked about a plot can now find diff --git a/app/project-version.ts b/app/project-version.ts new file mode 100644 index 0000000000..0074b5b4b4 --- /dev/null +++ b/app/project-version.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * Read the project version from the repo-root `pyproject.toml`. + * + * `pyproject.toml` is the single source of truth for the release version: the + * release flow (`agentic/commands/release.md`) bumps it, tags `vX.Y.Z`, and + * publishes the matching GitHub release. Vite injects the value as + * `__APP_VERSION__` so the frontend has an honest version at build time — with + * no second version field anyone has to remember to bump. + * + * Used by `vite.config.ts` and `vitest.config.ts`; runs in Node at config time, + * never in the browser bundle. + */ +export function readProjectVersion(): string { + const path = fileURLToPath(new URL('../pyproject.toml', import.meta.url)); + // Line-anchored so `python_version = "3.13"` under [tool.mypy] can't match; + // the `[project]` table's own key is the only one at column 0. + const match = /^version\s*=\s*"([^"]+)"/m.exec(readFileSync(path, 'utf8')); + if (!match) throw new Error(`No [project] version found in ${path}`); + return match[1]; +} diff --git a/app/security-headers.conf b/app/security-headers.conf index ce969e3c5b..ef73e7ab79 100644 --- a/app/security-headers.conf +++ b/app/security-headers.conf @@ -17,9 +17,12 @@ # - connect plausible.io: analytics is proxied through /js/script.js and # /api/event ('self'), the direct host is allowed as a safety margin. # - Plausible pageviews/events go to 'self' (/api/event), covered by connect-src. +# - connect api.github.com: the masthead reads the latest release tag from the +# GitHub releases API (useLatestRelease.ts). Omitting it does not break the +# SPA, it silently pins the masthead to its build-time fallback version. add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; # 180 days — moderate max-age, no includeSubDomains/preload (conservative first rollout). add_header Strict-Transport-Security "max-age=15552000" always; -add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://storage.googleapis.com https://api.anyplot.ai; font-src 'self' data: https://storage.googleapis.com; connect-src 'self' https://api.anyplot.ai https://storage.googleapis.com https://plausible.io; frame-src 'self' https://api.anyplot.ai; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://storage.googleapis.com https://api.anyplot.ai; font-src 'self' data: https://storage.googleapis.com; connect-src 'self' https://api.anyplot.ai https://storage.googleapis.com https://plausible.io https://api.github.com; frame-src 'self' https://api.anyplot.ai; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; diff --git a/app/src/global-config.test.ts b/app/src/global-config.test.ts new file mode 100644 index 0000000000..ee8ad1fd5d --- /dev/null +++ b/app/src/global-config.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { CONFIG } from 'src/global-config'; + +describe('CONFIG.appVersion', () => { + // Guards the `__APP_VERSION__` injection wired up in vite.config.ts and + // vitest.config.ts. Drop the define from either one and this fails loudly + // rather than silently shipping an undefined version — which is what the + // masthead falls back to whenever the GitHub releases API is blocked or + // rate-limits the visitor. + // + // The value's provenance is guarded at build time instead of here: + // readProjectVersion() (app/project-version.ts) throws when it cannot find + // the [project] version in pyproject.toml, and the frontend has no second + // version field left to drift from. Re-reading the file in this suite is not + // an option either — the frontend does not depend on @types/node, and + // `yarn type-check` type-checks the tests via tsconfig.test.json. + it('is injected from pyproject.toml as a semver triple', () => { + expect(CONFIG.appVersion).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/app/src/global-config.ts b/app/src/global-config.ts index 0151279fc0..41fd74205e 100644 --- a/app/src/global-config.ts +++ b/app/src/global-config.ts @@ -1,5 +1,3 @@ -import packageJson from '../package.json'; - interface GlobalConfig { appName: string; appVersion: string; @@ -14,7 +12,10 @@ const apiBaseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; export const CONFIG: GlobalConfig = { appName: 'anyplot', - appVersion: packageJson.version, + // Build-time version from the repo-root pyproject.toml — the same file the + // release flow bumps. app/package.json is private npm metadata and drifts + // (it sat at 2.0.0 through the 3.x releases), so it is not the source here. + appVersion: __APP_VERSION__, api: { baseUrl: apiBaseUrl, // DebugPage uses this — set to "/api" in prod (same-origin via the diff --git a/app/src/layouts/MastheadRule.test.tsx b/app/src/layouts/MastheadRule.test.tsx index 996290569e..807607ba2f 100644 --- a/app/src/layouts/MastheadRule.test.tsx +++ b/app/src/layouts/MastheadRule.test.tsx @@ -6,19 +6,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { CONFIG } from 'src/global-config'; import { render, screen, userEvent } from 'src/test-utils'; const trackEvent = vi.fn(); const cycle = vi.fn(); const setMode = vi.fn(); +// Mutable so a test can drive the null case — the GitHub releases API is +// unreachable, rate-limited, or simply hasn't answered yet. +let releaseTag: string | null = 'v1.2.3'; + vi.mock('src/hooks', async () => { const actual = await vi.importActual('src/hooks'); return { ...actual, useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }), useTheme: () => ({ mode: 'system', effective: 'light', isDark: false, setMode, cycle }), - useLatestRelease: () => 'v1.2.3', + useLatestRelease: () => releaseTag, }; }); @@ -38,6 +43,7 @@ describe('MastheadRule', () => { trackEvent.mockClear(); cycle.mockClear(); setMode.mockClear(); + releaseTag = 'v1.2.3'; }); it('fires theme_toggle event with the next mode and cycles', async () => { @@ -70,6 +76,25 @@ describe('MastheadRule', () => { }); }); + it('falls back to the build-time project version when no release tag resolves', () => { + releaseTag = null; + render(); + + // Never a hardcoded literal: the fallback tracks the released version, so a + // blocked or rate-limited GitHub API cannot pin the masthead to an old one. + expect(screen.getByText(`v${CONFIG.appVersion}`)).toBeInTheDocument(); + expect(screen.queryByText('v1.0')).toBeNull(); + }); + + it('links the release slot to the releases index while the tag is unresolved', () => { + releaseTag = null; + const { container } = renderAt('/', ); + const releaseLink = container.querySelector( + 'a[href="https://github.com/MarkusNeusinger/anyplot/releases"]' + ); + expect(releaseLink).not.toBeNull(); + }); + it('keeps the `~/anyplot.ai` root marker visible on xs for short breadcrumbs', () => { const { container } = renderAt('/palette', ); const rootMarker = container.querySelector('a[href="/"]'); diff --git a/app/src/layouts/MastheadRule.tsx b/app/src/layouts/MastheadRule.tsx index 567028f5a4..c720b38be6 100644 --- a/app/src/layouts/MastheadRule.tsx +++ b/app/src/layouts/MastheadRule.tsx @@ -6,6 +6,7 @@ import Box from '@mui/material/Box'; import { ThemeToggle } from 'src/components/ThemeToggle'; import { LANG_EXT, LIB_ABBREV } from 'src/constants'; +import { CONFIG } from 'src/global-config'; import { useAnalytics, useLatestRelease, useTheme } from 'src/hooks'; import { paths, RESERVED_TOP_LEVEL, specPath } from 'src/routes/paths'; import { colors, typography } from 'src/theme'; @@ -115,7 +116,10 @@ export function MastheadRule() { const location = useLocation(); const segments = pathSegments(location.pathname); const isLanding = segments.length === 0; - const version = releaseTag ?? 'v1.0'; + // The release tag arrives asynchronously (and can stay null when the GitHub + // API is unreachable or rate-limited), so the fallback is on screen for every + // cold load. Keep it truthful: the build-time project version, not a literal. + const version = releaseTag ?? `v${CONFIG.appVersion}`; const NEXT_MODE = { system: 'light', light: 'dark', dark: 'system' } as const; const handleThemeToggle = () => { diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts index 9e1e1857fb..5939eb94c4 100644 --- a/app/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -9,6 +9,10 @@ interface ImportMeta { readonly env: ImportMetaEnv; } +// Injected by vite.config.ts / vitest.config.ts from the [project] version in +// the repo-root pyproject.toml (see app/project-version.ts). +declare const __APP_VERSION__: string; + // Deep ESM imports not covered by @types/react-syntax-highlighter declare module 'react-syntax-highlighter/dist/esm/prism-light'; declare module 'react-syntax-highlighter/dist/esm/styles/prism'; diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json index 42872c59f5..555e1a326b 100644 --- a/app/tsconfig.node.json +++ b/app/tsconfig.node.json @@ -6,5 +6,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts", "project-version.ts"] } diff --git a/app/vite.config.ts b/app/vite.config.ts index 8425a54075..46cea5b392 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -5,7 +5,12 @@ import react from '@vitejs/plugin-react-swc'; import { checker } from 'vite-plugin-checker'; import { compression } from 'vite-plugin-compression2'; +import { readProjectVersion } from './project-version'; + export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(readProjectVersion()), + }, resolve: { alias: { src: fileURLToPath(new URL('./src', import.meta.url)), diff --git a/app/vitest.config.ts b/app/vitest.config.ts index eddaccbabf..42f2a3fa5c 100644 --- a/app/vitest.config.ts +++ b/app/vitest.config.ts @@ -2,7 +2,14 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +import { readProjectVersion } from './project-version'; + export default defineConfig({ + // Mirrors vite.config.ts — the test run compiles the same `__APP_VERSION__` + // reference in global-config.ts, and vitest does not read vite.config.ts. + define: { + __APP_VERSION__: JSON.stringify(readProjectVersion()), + }, resolve: { alias: { src: fileURLToPath(new URL('./src', import.meta.url)),