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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions app/project-version.ts
Original file line number Diff line number Diff line change
@@ -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];
}
5 changes: 4 additions & 1 deletion app/security-headers.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
21 changes: 21 additions & 0 deletions app/src/global-config.test.ts
Original file line number Diff line number Diff line change
@@ -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+/);
});
});
7 changes: 4 additions & 3 deletions app/src/global-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import packageJson from '../package.json';

interface GlobalConfig {
appName: string;
appVersion: string;
Expand All @@ -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
Expand Down
27 changes: 26 additions & 1 deletion app/src/layouts/MastheadRule.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('src/hooks')>('src/hooks');
return {
...actual,
useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }),
useTheme: () => ({ mode: 'system', effective: 'light', isDark: false, setMode, cycle }),
useLatestRelease: () => 'v1.2.3',
useLatestRelease: () => releaseTag,
};
});

Expand All @@ -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 () => {
Expand Down Expand Up @@ -70,6 +76,25 @@ describe('MastheadRule', () => {
});
});

it('falls back to the build-time project version when no release tag resolves', () => {
releaseTag = null;
render(<MastheadRule />);

// 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('/', <MastheadRule />);
const releaseLink = container.querySelector<HTMLAnchorElement>(
'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', <MastheadRule />);
const rootMarker = container.querySelector<HTMLAnchorElement>('a[href="/"]');
Expand Down
6 changes: 5 additions & 1 deletion app/src/layouts/MastheadRule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = () => {
Expand Down
4 changes: 4 additions & 0 deletions app/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion app/tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "vitest.config.ts", "project-version.ts"]
}
5 changes: 5 additions & 0 deletions app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
7 changes: 7 additions & 0 deletions app/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Loading