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() { )} -- Selected: {imageFile.name} -
+ {imagePreview && ( ++ {imageFile?.name} +
++ {(imageFile?.size || 0) / 1024 / 1024 < 1 + ? `${Math.round((imageFile?.size || 0) / 1024)} KB` + : `${((imageFile?.size || 0) / 1024 / 1024).toFixed(2)} MB`} +
+ ++ {selectedImage?.name} +
++ {(selectedImage?.size ? (selectedImage.size / 1024).toFixed(1) : 0)} KB +
+Insufficient balance
++ You need {shortfall} more XLM to cover the fee. +
+ {isTestnet && ( +