diff --git a/.kiro/specs/analytics-integration/.config.kiro b/.kiro/specs/analytics-integration/.config.kiro new file mode 100644 index 00000000..83cd7da4 --- /dev/null +++ b/.kiro/specs/analytics-integration/.config.kiro @@ -0,0 +1 @@ +{"specId": "c32919a5-fe46-4bc6-984f-f99cbbd4a134", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/analytics-integration/requirements.md b/.kiro/specs/analytics-integration/requirements.md new file mode 100644 index 00000000..a7d1cce9 --- /dev/null +++ b/.kiro/specs/analytics-integration/requirements.md @@ -0,0 +1,122 @@ +# Requirements Document + +## Introduction + +StellarForge currently has no visibility into how users interact with the application. This feature adds a privacy-respecting analytics integration (Plausible) to track key user actions and page views, enabling the team to prioritize features and identify drop-off points in the token creation flow — without collecting any personally identifiable information or wallet addresses. + +The analytics infrastructure is partially scaffolded in the codebase (`frontend/src/services/analytics.ts`, `frontend/src/hooks/useAnalytics.ts`, `frontend/src/components/AnalyticsOptOut.tsx`). This spec covers completing and hardening that implementation. + +## Glossary + +- **Analytics_Service**: The `analytics.ts` module responsible for sending events and page views to Plausible. +- **Plausible**: The privacy-respecting, cookieless analytics provider used for event and page view tracking. +- **Opt_Out_Store**: The `localStorage` key (`analytics_opt_out`) that persists the user's opt-out preference. +- **Token_Creation_Flow**: The multi-step user journey from navigating to `/create`, submitting the token form, and receiving a success or failure result. +- **PII**: Personally Identifiable Information — includes wallet addresses, names, email addresses, and any data that can identify an individual. +- **CSP**: Content Security Policy — the HTTP header or meta tag that restricts which external resources the browser may load. + +## Requirements + +### Requirement 1: Plausible Script Injection + +**User Story:** As a developer, I want the Plausible analytics script to be loaded only when configured, so that the app works correctly in environments without analytics credentials. + +#### Acceptance Criteria + +1. WHEN `VITE_PLAUSIBLE_DOMAIN` is set in the environment, THE Analytics_Service SHALL load the Plausible script by injecting a ` +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9c6b625f..ddd510a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@chromatic-com/storybook": "^3.2.6", + "@playwright/test": "^1.44.0", "@storybook/addon-essentials": "^8.6.12", "@storybook/addon-interactions": "^8.6.12", "@storybook/addon-links": "^8.6.12", @@ -2876,6 +2877,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -10025,6 +10042,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d0156e34..3dc4ee2a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "build-storybook": "storybook build", "analyze": "vite-bundle-analyzer dist", "build": "vite build", + "prebuild": "npx tsx scripts/generateCSP.ts", "dev": "vite", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx}\"", @@ -20,6 +21,7 @@ "prepare": "husky" }, "dependencies": { + "@sentry/react": "^10.46.0", "@stellar/freighter-api": "^6.0.1", "@tailwindcss/postcss": "^4.2.2", "i18next": "^25.10.9", @@ -32,6 +34,7 @@ }, "devDependencies": { "@chromatic-com/storybook": "^3.2.6", + "@playwright/test": "^1.44.0", "@storybook/addon-essentials": "^8.6.12", "@storybook/addon-interactions": "^8.6.12", "@storybook/addon-links": "^8.6.12", @@ -44,6 +47,7 @@ "@testing-library/react": "^16.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^8.57.2", "@typescript-eslint/parser": "^8.57.2", "@vitejs/plugin-react": "^4.7.0", @@ -51,7 +55,6 @@ "@vitest/ui": "^4.0.18", "autoprefixer": "^10.4.16", "eslint": "^10.1.0", - "@playwright/test": "^1.44.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/frontend/public/_headers b/frontend/public/_headers new file mode 100644 index 00000000..f76a4973 --- /dev/null +++ b/frontend/public/_headers @@ -0,0 +1,6 @@ +/* + Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://gateway.pinata.cloud; connect-src 'self' https://horizon.stellar.org https://horizon-testnet.stellar.org https://soroban-testnet.stellar.org https://rpc-mainnet.stellar.org https://gateway.pinata.cloud https://api.pinata.cloud https://*.ingest.sentry.io; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests; worker-src blob: + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: camera=(), microphone=(), geolocation=() diff --git a/frontend/public/health.json b/frontend/public/health.json new file mode 100644 index 00000000..361d2a2d --- /dev/null +++ b/frontend/public/health.json @@ -0,0 +1,4 @@ +{ + "status": "ok", + "version": "0.0.0" +} diff --git a/frontend/scripts/generateCSP.ts b/frontend/scripts/generateCSP.ts new file mode 100644 index 00000000..453b4737 --- /dev/null +++ b/frontend/scripts/generateCSP.ts @@ -0,0 +1,75 @@ +/** + * Build-time script: generates the CSP string from src/csp/policy.ts and + * writes it into vercel.json and public/_headers. + * + * Usage: + * npx tsx scripts/generateCSP.ts # write mode (run via prebuild) + * npx tsx scripts/generateCSP.ts --check # validate only, exit 1 on drift (CI) + */ + +import { readFileSync, writeFileSync } from 'fs' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' +import { CSP_DIRECTIVES, buildCSPString } from '../src/csp/policy.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = resolve(__dirname, '..') +const CHECK_ONLY = process.argv.includes('--check') + +const CSP = buildCSPString(CSP_DIRECTIVES) + +// ── vercel.json ─────────────────────────────────────────────────────────────── + +const vercelPath = resolve(root, 'vercel.json') +const vercel = JSON.parse(readFileSync(vercelPath, 'utf-8')) as { + headers: { source: string; headers: { key: string; value: string }[] }[] +} + +const vercelHeader = vercel.headers[0]?.headers.find((h) => h.key === 'Content-Security-Policy') + +if (!vercelHeader) { + console.error('generateCSP: Content-Security-Policy header not found in vercel.json') + process.exit(1) +} + +if (vercelHeader.value !== CSP) { + if (CHECK_ONLY) { + console.error('generateCSP: vercel.json CSP is out of sync with policy.ts') + console.error(' expected:', CSP) + console.error(' found: ', vercelHeader.value) + process.exit(1) + } + vercelHeader.value = CSP + writeFileSync(vercelPath, JSON.stringify(vercel, null, 2) + '\n') + console.log('generateCSP: updated vercel.json') +} else { + console.log('generateCSP: vercel.json is up to date') +} +// ── public/_headers ─────────────────────────────────────────────────────────── + +const headersPath = resolve(root, 'public/_headers') +const headersContent = readFileSync(headersPath, 'utf-8') + +const cspLineRegex = /^(\s*Content-Security-Policy:\s*)(.+)$/m +const match = headersContent.match(cspLineRegex) + +if (!match) { + console.error('generateCSP: Content-Security-Policy line not found in public/_headers') + process.exit(1) +} + +const existingCSP = match[2]?.trim() ?? '' + +if (existingCSP !== CSP) { + if (CHECK_ONLY) { + console.error('generateCSP: public/_headers CSP is out of sync with policy.ts') + console.error(' expected:', CSP) + console.error(' found: ', existingCSP) + process.exit(1) + } + const updated = headersContent.replace(cspLineRegex, `$1${CSP}`) + writeFileSync(headersPath, updated) + console.log('generateCSP: updated public/_headers') +} else { + console.log('generateCSP: public/_headers is up to date') +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2cb9694a..8d388605 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,11 @@ -import React from 'react' +import React, { useEffect } from 'react' import { ToastContainer, Button, Spinner } from './components/UI' import './App.css' import { useTranslation } from 'react-i18next' import { useDarkMode } from './hooks/useDarkMode' -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom' +import { trackEvent, trackPageView } from './services/analytics' +import { AnalyticsOptOut } from './components/AnalyticsOptOut' import { WalletProvider } from './context/WalletContext' import { ToastProvider, useToast } from './context/ToastContext' import { NetworkProvider } from './context/NetworkContext' @@ -42,17 +44,26 @@ function AppContent() { const { isDarkMode, toggleDarkMode } = useDarkMode() const [showOnboarding, setShowOnboarding] = useState(false) const { state: factoryState } = useFactoryState() + const location = useLocation() const isAdmin = !!wallet.address && !!factoryState?.admin && wallet.address === factoryState.admin const { theme, toggleTheme } = useTheme() + // Track page views on route changes + useEffect(() => { + trackPageView(location.pathname) + }, [location.pathname]) + const handleGetStarted = () => addToast(t('home.welcomeToast'), 'info') const handleConnect = async () => { try { await connect() - if (!error) addToast(t('wallet.connected'), 'success') + if (!error) { + addToast(t('wallet.connected'), 'success') + trackEvent('wallet_connected') + } } catch { addToast(t('wallet.connectFailed'), 'error') } @@ -183,8 +194,7 @@ function AppContent() { )} -
-
+
{error && (
} />
+
- -
-
- - - - - ) -} + + + + ) + } function App() { return ( diff --git a/frontend/src/components/AnalyticsOptOut.tsx b/frontend/src/components/AnalyticsOptOut.tsx new file mode 100644 index 00000000..881c7aaa --- /dev/null +++ b/frontend/src/components/AnalyticsOptOut.tsx @@ -0,0 +1,26 @@ +import { useAnalytics } from '../hooks/useAnalytics' + +/** + * A small toggle that lets users opt out of analytics tracking. + * Renders nothing when analytics is not configured. + */ +export const AnalyticsOptOut: React.FC = () => { + const { optedOut, toggleOptOut } = useAnalytics() + + if (!import.meta.env.VITE_PLAUSIBLE_DOMAIN) return null + + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/BurnForm.tsx b/frontend/src/components/BurnForm.tsx index ade651d3..a19144d9 100644 --- a/frontend/src/components/BurnForm.tsx +++ b/frontend/src/components/BurnForm.tsx @@ -24,6 +24,7 @@ export const BurnForm: React.FC = ({ const { wallet } = useWalletContext() const { addToast } = useToast() const { requireTos } = useTos() + const { hasSufficientBalance, shortfall, isTestnet } = useBalanceCheck(ESTIMATED_FEE_XLM) const [tokenAddress, setTokenAddress] = useState(initialAddress) const [amount, setAmount] = useState('') @@ -115,11 +116,14 @@ export const BurnForm: React.FC = ({ type="submit" variant="secondary" loading={isSubmitting} - disabled={isSubmitting || amountExceedsBalance} + disabled={isSubmitting || amountExceedsBalance || !hasSufficientBalance} className="w-full sm:w-auto" > Burn Tokens + {!hasSufficientBalance && ( + + )} { )}
- + + +
) diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 5332520c..a392db8b 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -20,9 +20,8 @@ class ErrorBoundary extends Component { return { hasError: true, error } } - componentDidCatch(_error: Error, _errorInfo: ErrorInfo): void { - // Errors are surfaced in the fallback UI; no console noise needed. - // Wire a real error reporter (e.g. Sentry) here if required. + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('[ErrorBoundary] Uncaught error:', error, errorInfo.componentStack) } render(): ReactNode { diff --git a/frontend/src/components/MetadataUploadForm.tsx b/frontend/src/components/MetadataUploadForm.tsx index 42464acf..dca35fa7 100644 --- a/frontend/src/components/MetadataUploadForm.tsx +++ b/frontend/src/components/MetadataUploadForm.tsx @@ -26,17 +26,40 @@ export const MetadataUploadForm: React.FC = ({ const ipfsReady = isIpfsConfigured() + const [imagePreview, setImagePreview] = useState(null) + const handleImageChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0] - if (!file) return + if (!file) { + setImageFile(null) + setImagePreview(null) + return + } const validation = isValidImageFile(file) if (!validation.valid) { addToast(validation.error || 'Invalid image file', 'error') + setImageFile(null) + setImagePreview(null) return } setImageFile(file) + + // Create preview + const reader = new FileReader() + reader.onload = (e) => { + setImagePreview(e.target?.result as string) + } + reader.readAsDataURL(file) + } + + const handleRemoveImage = () => { + setImageFile(null) + setImagePreview(null) + // Reset the input + const input = document.getElementById('image') as HTMLInputElement + if (input) input.value = '' } const handleSubmit = async (e: React.FormEvent) => { @@ -147,10 +170,31 @@ export const MetadataUploadForm: React.FC = ({ dark:file:bg-blue-900/30 dark:file:text-blue-300 hover:file:bg-blue-100 dark:hover:file:bg-blue-900/50" /> - {imageFile && ( -

- Selected: {imageFile.name} -

+ {imagePreview && ( +
+ Token preview +
+

+ {imageFile?.name} +

+

+ {(imageFile?.size || 0) / 1024 / 1024 < 1 + ? `${Math.round((imageFile?.size || 0) / 1024)} KB` + : `${((imageFile?.size || 0) / 1024 / 1024).toFixed(2)} MB`} +

+ +
+
)} diff --git a/frontend/src/components/MintForm.tsx b/frontend/src/components/MintForm.tsx index 7f3358f5..f0a1a532 100644 --- a/frontend/src/components/MintForm.tsx +++ b/frontend/src/components/MintForm.tsx @@ -6,11 +6,13 @@ import { useTos } from '../context/TosContext' import { useStellarContext } from '../context/StellarContext' import { useWalletContext } from '../context/WalletContext' import { useToast } from '../context/ToastContext' +import { useBalanceCheck } from '../hooks/useBalanceCheck' import { isValidStellarAddress } from '../utils/validation' import type { TokenInfo } from '../types' const BASE_FEE_STROOPS = '100000' // 0.01 XLM const ESTIMATED_FEE_DISPLAY = '0.01 XLM' +const ESTIMATED_FEE_XLM = 0.01 const ADDRESS_DEBOUNCE_DELAY = 500 interface MintFormProps { @@ -26,6 +28,7 @@ export const MintForm: React.FC = ({ const { wallet } = useWalletContext() const { addToast } = useToast() const { requireTos } = useTos() + const { hasSufficientBalance, shortfall, isTestnet } = useBalanceCheck(ESTIMATED_FEE_XLM) const [tokenAddress, setTokenAddress] = useState(initialAddress) const [recipient, setRecipient] = useState('') @@ -166,11 +169,14 @@ export const MintForm: React.FC = ({ type="submit" variant="primary" loading={isSubmitting} - disabled={isSubmitting} + disabled={isSubmitting || !hasSufficientBalance} className="w-full sm:w-auto" > Mint Tokens + {!hasSufficientBalance && ( + + )} = ({ const [pending, setPending] = useState(false) const { addToast } = useToast() const ipfsReady = isIpfsConfigured() + const { hasSufficientBalance, shortfall, isTestnet } = useBalanceCheck(ESTIMATED_FEE_XLM) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() @@ -76,10 +79,13 @@ export const SetMetadataForm: React.FC = ({ disabled={!ipfsReady} />
-
+ {!hasSufficientBalance && ( + + )} { const { stellarService } = useStellarContext() @@ -29,6 +30,8 @@ export const TokenCreateForm: React.FC = () => { const [decimals, setDecimals] = useState('7') const [initialSupply, setInitialSupply] = useState('') const [description, setDescription] = useState('') + const [selectedImage, setSelectedImage] = useState(null) + const [imagePreview, setImagePreview] = useState(null) const [deployedToken, setDeployedToken] = useState<{ address: string; name: string; symbol: string } | null>(null) const [pendingParams, setPendingParams] = useState(null) const [deploymentSteps, setDeploymentSteps] = useState([ @@ -42,6 +45,39 @@ export const TokenCreateForm: React.FC = () => { const { addToast } = useToast() const { requireTos } = useTos() const { t } = useTranslation() + const { hasSufficientBalance, shortfall, isTestnet } = useBalanceCheck(ESTIMATED_FEE_XLM) + + const handleImageSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + // Validate file type + if (!file.type.startsWith('image/')) { + addToast('Please select a valid image file', 'error') + return + } + + // Validate file size (e.g., max 5MB) + const maxSize = 5 * 1024 * 1024 // 5MB + if (file.size > maxSize) { + addToast('Image file is too large. Maximum size is 5MB', 'error') + return + } + + setSelectedImage(file) + + // Generate preview + const reader = new FileReader() + reader.onload = (e) => { + setImagePreview(e.target?.result as string) + } + reader.readAsDataURL(file) + } + + const removeImage = () => { + setSelectedImage(null) + setImagePreview(null) + } // Use a ref so the builder always sees the latest params without re-creating the hook const paramsRef = useRef(null) @@ -96,7 +132,7 @@ export const TokenCreateForm: React.FC = () => { tokenWasmHash: STELLAR_CONFIG.factoryContractId, feePayment: '100000', ...(sanitizedDescription && { - metadata: { description: sanitizedDescription, image: new File([], '') }, + metadata: { description: sanitizedDescription, image: selectedImage || new File([], '') }, }), } @@ -130,6 +166,8 @@ export const TokenCreateForm: React.FC = () => { setDecimals('7') setInitialSupply('') setDescription('') + setSelectedImage(null) + setImagePreview(null) await refreshBalance() } catch (err) { updateStep(0, 'error') @@ -222,9 +260,54 @@ export const TokenCreateForm: React.FC = () => { rows={3} /> + + {/* Image Upload */} +
+ + + {imagePreview && ( +
+
+ Token preview +
+

+ {selectedImage?.name} +

+

+ {(selectedImage?.size ? (selectedImage.size / 1024).toFixed(1) : 0)} KB +

+ +
+
+
+ )} +
+ + {!hasSufficientBalance && ( + + )} = ({ + shortfall, + isTestnet, +}) => { + const { wallet, refreshBalance } = useWalletContext() + const { addToast } = useToast() + const { fund, isLoading } = useFriendbot(async () => { + await refreshBalance() + addToast('Testnet XLM funded successfully!', 'success') + }) + + const handleFund = async () => { + try { + await fund(wallet.address!) + } catch (err) { + addToast(err instanceof Error ? err.message : 'Friendbot is currently unavailable', 'error') + } + } + + return ( +
+

Insufficient balance

+

+ You need {shortfall} more XLM to cover the fee. +

+ {isTestnet && ( + + )} +
+ ) +} diff --git a/frontend/src/components/UI/index.ts b/frontend/src/components/UI/index.ts index 8b2ae96b..02edb93b 100644 --- a/frontend/src/components/UI/index.ts +++ b/frontend/src/components/UI/index.ts @@ -3,6 +3,7 @@ export { Card } from './Card' export { Checkbox } from './Checkbox' export { ConfirmModal } from './ConfirmModal' export type { DetailRow } from './ConfirmModal' +export { InsufficientBalanceWarning } from './InsufficientBalanceWarning' export { Input } from './Input' export { MainnetConfirmationModal } from './MainnetConfirmationModal' export { PaginationControls } from './PaginationControls' diff --git a/frontend/src/csp/cspReporter.ts b/frontend/src/csp/cspReporter.ts new file mode 100644 index 00000000..3d8817c9 --- /dev/null +++ b/frontend/src/csp/cspReporter.ts @@ -0,0 +1,28 @@ +import { captureException } from '../lib/monitoring/sentry' + +let registered = false + +/** + * Registers a SecurityPolicyViolationEvent listener that forwards CSP + * violations to Sentry in production and console.warn in development. + * Safe to call multiple times — registers exactly once. + */ +export function registerCSPReporter(): void { + if (registered) return + registered = true + + document.addEventListener('securitypolicyviolation', (e: SecurityPolicyViolationEvent) => { + const context = { + directive: e.violatedDirective, + blockedURI: e.blockedURI, + originalPolicy: e.originalPolicy, + } + + if (import.meta.env.MODE !== 'production') { + console.warn('[CSP violation]', e.violatedDirective, e.blockedURI, e) + return + } + + captureException(new Error(`CSP violation: ${e.blockedURI}`), context) + }) +} diff --git a/frontend/src/csp/policy.ts b/frontend/src/csp/policy.ts new file mode 100644 index 00000000..694f7279 --- /dev/null +++ b/frontend/src/csp/policy.ts @@ -0,0 +1,106 @@ +/** + * Single source of truth for the Content Security Policy. + * + * All deployment configs (vercel.json, public/_headers) are generated from + * this file via `scripts/generateCSP.ts`. Never hardcode the CSP string + * elsewhere — run `npm run prebuild` to sync configs after editing this file. + */ + +export type CSPDirectiveValue = string[] + +export type CSPDirectives = { + /** Fallback for fetch directives not explicitly listed. */ + 'default-src': CSPDirectiveValue + /** + * Controls which scripts can execute. + * 'unsafe-inline' and 'unsafe-eval' are intentionally absent — they negate + * XSS protection entirely. Vite bundles everything into hashed chunks so + * no inline scripts are needed in production. + */ + 'script-src': CSPDirectiveValue + /** + * 'unsafe-inline' is required because Tailwind CSS injects styles at + * runtime via the style attribute and