From 9a1ba2eb7ce7c7cdd95d401c5d5e8a28922c49af Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Mon, 27 Jul 2026 21:00:38 +0300 Subject: [PATCH 01/29] Add Supporter plan, cloud backend, and SEO pages (1.1.0) Adds an optional account and Supporter tier on top of the existing editor, plus build-time SEO landing pages. The core editor stays free and fully offline: with none of the new environment variables set, every cloud feature hides itself and the app behaves exactly as before. Accounts and cloud (Supabase) - Email + password and Google sign-in, with password reset. - Cloud sync for graphs, projects and custom templates, with version history. - View-only share links on unguessable slugs, resolved anonymously through a get_share() RPC so the shares table is never readable by anon. - Full schema in supabase/schema.sql with RLS on every table. Entitlement is one rule, pro_until > now(), enforced identically in SQL and TypeScript. Hosted AI - /api/generate runs generation server-side for supporters. - Three interchangeable backends, first configured wins: Vertex AI express key, Vertex AI with a project (ADC locally, service account on Vercel), or a Google AI Studio key. - Metered per user per month via atomic SQL, default 150. Upstream failures are refunded; a response that arrives but fails to parse is not, so it cannot be farmed. - Free users keep unlimited generation with their own key. Billing (Polar) - Checkout, customer portal, and a signature-verified webhook that is the only writer of billing columns. - Renewal is cushioned by a 1-day margin and never moved backward by a delayed or out-of-order event; cancellation still ends access immediately. - Account deletion cancels any live subscription before deleting. SEO - 12 static diagram pages plus a hub and sitemap.xml, generated at build time into dist/. public/sitemap.xml is removed because it is now generated. Ops - db-keepalive.yml pings the database every ~5 days so a free-tier Supabase project never pauses; update-supporters.yml refreshes the supporters list. Licensing - AGPL-3.0, with the section 13 source offer linked from Settings. The project name, logo and branding are reserved separately from the code licence, so forks run under their own branding. - Privacy Policy and Terms pages, governed by Finnish law and preserving EU/EEA consumer rights. --- .env.example | 62 + .github/workflows/db-keepalive.yml | 47 + .github/workflows/update-supporters.yml | 46 + .gitignore | 3 + App.tsx | 372 +- CHANGELOG.md | 62 +- README.md | 134 +- api/_lib/polar.ts | 43 + api/_lib/supabaseAdmin.ts | 73 + api/checkout.ts | 69 + api/delete-account.ts | 82 + api/generate.ts | 205 ++ api/portal.ts | 38 + api/usage.ts | 46 + api/webhooks/polar.ts | 165 + components/AccountSection.tsx | 487 +++ components/AuthModal.tsx | 261 ++ components/CloudHistoryModal.tsx | 102 + components/ComparePage.tsx | 268 ++ components/ComponentLibrary.tsx | 200 +- components/LandingPage.tsx | 81 +- components/LegalPages.tsx | 254 ++ components/PricingPage.tsx | 329 ++ components/SettingsPage.tsx | 112 +- components/ShareModal.tsx | 182 + components/SharedViewPage.tsx | 179 + docs/BACKEND_SETUP.md | 261 ++ index.html | 6 +- index.tsx | 7 +- package-lock.json | 4183 ++++++++++++++++++----- package.json | 12 +- public/sitemap.xml | 27 - scripts/generate-seo-pages.mjs | 418 +++ scripts/seo-content.mjs | 618 ++++ scripts/update-supporters.mjs | 61 + services/ai.ts | 8 + services/aiProvider.ts | 11 +- services/auth.tsx | 237 ++ services/billing.ts | 56 + services/cloudErrors.ts | 9 + services/customTemplates.ts | 149 + services/diagramPrompt.ts | 125 + services/entitlement.ts | 13 + services/gemini.ts | 149 +- services/hostedAi.ts | 60 + services/keyObfuscation.ts | 14 + services/openrouter.ts | 16 +- services/shares.ts | 150 + services/supabaseClient.ts | 28 + services/sync.ts | 539 +++ services/useCloudSync.ts | 149 + supabase/schema.sql | 488 +++ vercel.json | 4 +- vite-env.d.ts | 10 + vite.config.ts | 141 +- 55 files changed, 10730 insertions(+), 1121 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/db-keepalive.yml create mode 100644 .github/workflows/update-supporters.yml create mode 100644 api/_lib/polar.ts create mode 100644 api/_lib/supabaseAdmin.ts create mode 100644 api/checkout.ts create mode 100644 api/delete-account.ts create mode 100644 api/generate.ts create mode 100644 api/portal.ts create mode 100644 api/usage.ts create mode 100644 api/webhooks/polar.ts create mode 100644 components/AccountSection.tsx create mode 100644 components/AuthModal.tsx create mode 100644 components/CloudHistoryModal.tsx create mode 100644 components/ComparePage.tsx create mode 100644 components/LegalPages.tsx create mode 100644 components/PricingPage.tsx create mode 100644 components/ShareModal.tsx create mode 100644 components/SharedViewPage.tsx create mode 100644 docs/BACKEND_SETUP.md delete mode 100644 public/sitemap.xml create mode 100644 scripts/generate-seo-pages.mjs create mode 100644 scripts/seo-content.mjs create mode 100644 scripts/update-supporters.mjs create mode 100644 services/auth.tsx create mode 100644 services/billing.ts create mode 100644 services/cloudErrors.ts create mode 100644 services/customTemplates.ts create mode 100644 services/diagramPrompt.ts create mode 100644 services/entitlement.ts create mode 100644 services/hostedAi.ts create mode 100644 services/keyObfuscation.ts create mode 100644 services/shares.ts create mode 100644 services/supabaseClient.ts create mode 100644 services/sync.ts create mode 100644 services/useCloudSync.ts create mode 100644 supabase/schema.sql create mode 100644 vite-env.d.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9d1d07f --- /dev/null +++ b/.env.example @@ -0,0 +1,62 @@ +# ───────────────────────────────────────────────────────────────────────────── +# IB EconGraph AI — environment variables +# +# Everything here is OPTIONAL. Without any of it the app runs fully free & +# local (BYOK AI keys are entered in the UI, data lives in localStorage). +# Configure these only if you want accounts, cloud sync, and the Supporter +# plan on your own deployment. See docs/BACKEND_SETUP.md for the full guide. +# ───────────────────────────────────────────────────────────────────────────── + +# ── Client (bundled into the frontend by Vite — safe to expose) ───────────── +# Supabase project URL + publishable key (sb_publishable_…, Project Settings → +# API Keys). Low-privilege, safe in the client bundle. Enables sign-in/sync/share. +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= + +# ── Server (Vercel project env vars — NEVER commit real values) ───────────── +# Supabase secret key (sb_secret_…, Project Settings → API Keys). Bypasses RLS; +# server only — Supabase rejects it if sent from a browser. +SUPABASE_URL= +SUPABASE_SECRET_KEY= + +# ── Hosted AI (Supporter plan) ────────────────────────────────────────────── +# The server generates diagrams for supporters using ONE of three backends. +# They are tried in the order below; the first one that is configured wins. +# +# 1) Vertex AI express mode: a single API key, no service account, so it works +# on serverless out of the box. Create one in the Google Cloud console under +# "Gemini Enterprise Agent Platform" (the 2026 rebrand of Vertex AI), express +# mode. NOTE: creating a Vertex API key requires a Google Cloud organization; +# a personal @gmail.com account with no org is blocked and should use (2). +VERTEX_API_KEY= +# +# 2) Vertex AI with a project id (and optional location, default "global"). +# Locally this authenticates with your gcloud Application Default +# Credentials: run `gcloud auth application-default login` once. On hosts +# without gcloud (e.g. Vercel), also paste a service-account key JSON as a +# single line into GOOGLE_SERVICE_ACCOUNT_JSON. +GOOGLE_CLOUD_PROJECT= +GOOGLE_CLOUD_LOCATION=global +GOOGLE_SERVICE_ACCOUNT_JSON= +# +# 3) Gemini Developer API (Google AI Studio): the simplest fully-free option. +# Get a key at https://aistudio.google.com/apikey +GEMINI_API_KEY= + +# Shared hosted-AI settings, applied to whichever backend above is active. +HOSTED_AI_MONTHLY_LIMIT=150 +HOSTED_AI_MODEL=gemini-2.5-flash + +# Polar billing (https://polar.sh). Use POLAR_SERVER=sandbox while testing. +POLAR_ACCESS_TOKEN= +POLAR_WEBHOOK_SECRET= +POLAR_PRODUCT_ID_MONTHLY= +POLAR_PRODUCT_ID_YEARLY= +POLAR_SERVER=production + +# Public URL of the deployment, used to pin checkout redirects to a canonical +# domain. Optional: if left blank the server uses the incoming request's origin +# (your real domain), which is correct for most setups. Set it only to force a +# specific domain, e.g. when *.vercel.app preview aliases should redirect to +# your primary URL. Example: https://your-app.vercel.app +APP_URL= diff --git a/.github/workflows/db-keepalive.yml b/.github/workflows/db-keepalive.yml new file mode 100644 index 0000000..4bcafd9 --- /dev/null +++ b/.github/workflows/db-keepalive.yml @@ -0,0 +1,47 @@ +name: DB keepalive + +# Supabase free-tier projects pause after 7 days of inactivity. This makes a +# cheap read against the database every ~5 days to keep it awake, leaving a +# safe margin under the 7-day pause window. +# +# Reuses the same repository secrets as the supporters workflow +# (Settings, then Secrets and variables, then Actions): +# SUPABASE_URL your Supabase project URL +# SUPABASE_SECRET_KEY the Supabase secret key (sb_secret_...) + +on: + schedule: + # Runs on days 1,6,11,16,21,26,31 -> a gap of at most 5 days, always under 7. + - cron: '0 6 */5 * *' + workflow_dispatch: {} + +concurrency: + group: db-keepalive + cancel-in-progress: false + +jobs: + ping: + runs-on: ubuntu-latest + steps: + - name: Ping the database + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }} + run: | + if [ -z "$SUPABASE_URL" ] || [ -z "$SUPABASE_SECRET_KEY" ]; then + echo "Supabase secrets are not set; nothing to ping." + exit 0 + fi + # One-row read via PostgREST. The secret key bypasses RLS, so this is a + # trivial query that still counts as real database activity. + code=$(curl -s -o /dev/null -w '%{http_code}' \ + "$SUPABASE_URL/rest/v1/profiles?select=id&limit=1" \ + -H "apikey: $SUPABASE_SECRET_KEY" \ + -H "Authorization: Bearer $SUPABASE_SECRET_KEY") + echo "Supabase responded: HTTP $code" + # 200 (rows) and 206 (partial content) both mean the query was served. + if [ "$code" != "200" ] && [ "$code" != "206" ]; then + echo "Unexpected status $code; keepalive may have failed." + exit 1 + fi + echo "Keepalive ping OK." diff --git a/.github/workflows/update-supporters.yml b/.github/workflows/update-supporters.yml new file mode 100644 index 0000000..094f11f --- /dev/null +++ b/.github/workflows/update-supporters.yml @@ -0,0 +1,46 @@ +name: Update supporters + +# Refreshes the Supporters block in README.md from the database on a schedule. +# Requires two repository secrets (Settings → Secrets and variables → Actions): +# SUPABASE_URL — your Supabase project URL +# SUPABASE_SECRET_KEY — the Supabase secret key (sb_secret_…) + +on: + schedule: + - cron: '0 6 * * 1' # every Monday at 06:00 UTC + workflow_dispatch: {} # allow manual runs from the Actions tab + +permissions: + contents: write + +# Never run two updates at once. +concurrency: + group: update-supporters + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - name: Refresh supporters block + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }} + run: node scripts/update-supporters.mjs + - name: Commit if the README changed + run: | + if [ -n "$(git status --porcelain README.md)" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "chore: refresh supporters list" + git push + else + echo "No supporter changes to commit." + fi diff --git a/.gitignore b/.gitignore index ffc3818..c6da18d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ lerna-debug.log* # OS files .DS_Store Thumbs.db +.vercel +.env* +!.env.example diff --git a/App.tsx b/App.tsx index f8db4a6..6947103 100644 --- a/App.tsx +++ b/App.tsx @@ -1,10 +1,20 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { generateDiagramData, hasApiKey } from './services/ai'; +import { getAIProvider } from './services/aiProvider'; +import { useAuth } from './services/auth'; +import { useCloudSync } from './services/useCloudSync'; +import { recordTombstones, clearTombstones, fetchCloudIds } from './services/sync'; import DiagramRenderer from './components/DiagramRenderer'; import LandingPage from './components/LandingPage'; import HomePage from './components/HomePage'; import SettingsPage from './components/SettingsPage'; +import PricingPage from './components/PricingPage'; +import ComparePage from './components/ComparePage'; +import { PrivacyPage, TermsPage } from './components/LegalPages'; +import SharedViewPage from './components/SharedViewPage'; +import ShareModal from './components/ShareModal'; +import CloudHistoryModal from './components/CloudHistoryModal'; import ToolbarLeft from './components/ToolbarLeft'; import ToolbarRight from './components/ToolbarRight'; import ComponentLibrary from './components/ComponentLibrary'; @@ -13,7 +23,8 @@ import { usePortalTooltip } from './components/usePortalTooltip'; import { DiagramData, INITIAL_DIAGRAM, EMPTY_DIAGRAM, Graph, Project, Message, EditorTool, EditorSettings, ComponentTemplate } from './types'; import { Loader2, Send, Plus, MessageSquare, BarChart2, - Trash2, Menu, History, RotateCcw, RotateCw, FolderOpen, ChevronLeft, Grid3X3, AlertTriangle, Settings + Trash2, Menu, History, RotateCcw, RotateCw, FolderOpen, ChevronLeft, Grid3X3, AlertTriangle, Settings, + Share2, CloudDownload } from 'lucide-react'; const generateId = () => uuidv4(); @@ -23,7 +34,11 @@ const STORAGE_KEYS = { projects: 'econgraph_projects', settings: 'econgraph_settings', specialColors: 'econgraph_special_colors', - standardColors: 'econgraph_standard_colors' + standardColors: 'econgraph_standard_colors', + // Which account the locally-stored graphs/projects belong to. The store is + // global (not per-user), so this lets us detect an account switch on a shared + // browser and avoid attributing one person's diagrams to another. + owner: 'econgraph_owner' }; const DEFAULT_STANDARD_COLORS = [ @@ -55,18 +70,28 @@ const PROJECT_COLORS = [ '#ec4899', // Pink ]; -type ViewType = 'landing' | 'home' | 'editor' | 'settings'; +type ViewType = 'landing' | 'home' | 'editor' | 'settings' | 'pricing' | 'compare' | 'shared' | 'privacy' | 'terms'; + +function parsePath(pathname: string): { view: ViewType; sharedSlug: string | null } { + if (pathname === '/home') return { view: 'home', sharedSlug: null }; + if (pathname === '/editor') return { view: 'editor', sharedSlug: null }; + if (pathname === '/settings') return { view: 'settings', sharedSlug: null }; + if (pathname === '/pricing') return { view: 'pricing', sharedSlug: null }; + if (pathname === '/compare') return { view: 'compare', sharedSlug: null }; + if (pathname === '/privacy') return { view: 'privacy', sharedSlug: null }; + if (pathname === '/terms') return { view: 'terms', sharedSlug: null }; + const shareMatch = pathname.match(/^\/s\/([A-Za-z0-9_-]{6,64})\/?$/); + if (shareMatch) return { view: 'shared', sharedSlug: shareMatch[1] }; + return { view: 'landing', sharedSlug: null }; // default for '/' and unknown paths +} + +/** Why AI generation is unavailable, or null when it's usable. */ +type AiGate = null | 'hosted-signin' | 'hosted-upgrade' | 'byok-nokey'; export default function App() { // --- View State --- - const [view, setView] = useState(() => { - // Initialize view based on URL path - const path = window.location.pathname; - if (path === '/home') return 'home'; - if (path === '/editor') return 'editor'; - if (path === '/settings') return 'settings'; - return 'landing'; // default to landing for '/' and any other path - }); + const [view, setView] = useState(() => parsePath(window.location.pathname).view); + const [sharedSlug, setSharedSlug] = useState(() => parsePath(window.location.pathname).sharedSlug); // --- Data State --- const [graphs, setGraphs] = useState([]); @@ -127,6 +152,8 @@ export default function App() { }>({ visible: false, currentColor: '#3b82f6', onSelect: () => { } }); const [exportModalOpen, setExportModalOpen] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); + const [cloudHistoryOpen, setCloudHistoryOpen] = useState(false); // History for undo/redo const [history, setHistory] = useState([]); @@ -139,6 +166,52 @@ export default function App() { const { showTooltip: showSendTooltip, hideTooltip: hideSendTooltip, TooltipPortal: SendTooltipPortal } = usePortalTooltip({ delay: 400, placement: 'top' }); + // --- Cloud (accounts + sync are Supporter features; app is fully usable without) --- + const { configured: cloudConfigured, user, isPro } = useAuth(); + + // Live refs so applyRemote (a stable, dep-free callback) can see the graph + // currently open in the editor without being re-created on every edit. + const activeGraphIdRef = useRef(null); + const currentDiagramRef = useRef(INITIAL_DIAGRAM); + + const applyRemote = useCallback((remoteGraphs: Graph[], remoteProjects: Project[]) => { + setGraphs(remoteGraphs); + setProjects(remoteProjects); + // If the graph open in the editor was changed by this pull (e.g. edited on + // another device), refresh the editor's live copy, otherwise the next + // autosave writes our stale currentDiagram back over the newer cloud version. + // BUT only when there are no unsaved local edits in flight: a pending + // autosave means currentDiagram holds edits not yet written to `graphs`, and + // overwriting it here would silently discard them and reset the undo stack. + const openId = activeGraphIdRef.current; + if (openId && autosaveDebounceRef.current === null) { + const incoming = remoteGraphs.find((g) => g.id === openId); + if (incoming && JSON.stringify(incoming.diagramData) !== JSON.stringify(currentDiagramRef.current)) { + setCurrentDiagram(incoming.diagramData); + setHistory([incoming.diagramData]); + historyRef.current = [incoming.diagramData]; + historyIndexRef.current = 0; + setHistoryIndex(0); + } + } + }, []); + + const { syncState, syncNow } = useCloudSync({ + userId: user && isPro ? user.id : null, + hasInitialized, + graphs, + projects, + applyRemote, + }); + + // A signed-in Supporter's local store can be empty simply because the first + // cloud pull hasn't landed yet, used below to avoid creating (and syncing + // up) a throwaway blank graph before we've heard whether the cloud has data. + const awaitingFirstPull = + cloudConfigured && !!user && isPro && + syncState.lastSyncedAt === null && + (syncState.status === 'idle' || syncState.status === 'syncing'); + // --- Load from localStorage on mount --- useEffect(() => { try { @@ -178,6 +251,31 @@ export default function App() { setHasInitialized(true); }, []); + // --- Guard against cross-account data bleed on a shared browser --- + // The local store is global (not per-user). When a DIFFERENT account signs in, + // the previous user's diagrams must not be treated as (and synced up into) the + // new account. Anonymous local work (no recorded owner) is still migrated to + // the first account that signs in; the same user signing back in keeps theirs. + useEffect(() => { + if (!hasInitialized) return; + const uid = user?.id ?? null; + if (!uid) return; // signed out: leave local data + owner untouched + let owner: string | null = null; + try { owner = localStorage.getItem(STORAGE_KEYS.owner); } catch { /* ignore */ } + if (owner && owner !== uid) { + // Someone else's local data, clear it so it isn't attributed to this + // account. Their data is safe in their own cloud (if a Supporter). + setGraphs([]); + setProjects([]); + setActiveGraphId(null); + } + try { localStorage.setItem(STORAGE_KEYS.owner, uid); } catch { /* ignore */ } + }, [user?.id, hasInitialized]); + + // Keep live refs in sync for dep-free callbacks (see applyRemote). + useEffect(() => { activeGraphIdRef.current = activeGraphId; }, [activeGraphId]); + useEffect(() => { currentDiagramRef.current = currentDiagram; }, [currentDiagram]); + // --- Auto-open most recent graph logic --- useEffect(() => { // Only run when navigating to editor without an active graph, after initialization @@ -195,6 +293,9 @@ export default function App() { historyRef.current = [mostRecent.diagramData]; setHistoryIndex(0); } else if (graphs.length === 0) { + // Wait for the first cloud pull before assuming a Supporter has no graphs + //, otherwise we'd create a blank one and sync it up as clutter. + if (awaitingFirstPull) return; // Create new graph if none exist const newGraph: Graph = { id: generateId(), @@ -217,7 +318,7 @@ export default function App() { historyRef.current = [newGraph.diagramData]; setHistoryIndex(0); } - }, [view, hasInitialized, activeGraphId, graphs.length]); // Use graphs.length instead of graphs to avoid re-trigger on content changes + }, [view, hasInitialized, activeGraphId, graphs.length, awaitingFirstPull]); // Use graphs.length instead of graphs to avoid re-trigger on content changes // --- Save to localStorage when data changes (only after initial load) --- useEffect(() => { @@ -282,21 +383,46 @@ export default function App() { // Listen for browser back/forward navigation useEffect(() => { const handlePopState = () => { - const path = window.location.pathname; - if (path === '/home') setView('home'); - else if (path === '/editor') setView('editor'); - else if (path === '/settings') setView('settings'); - else setView('landing'); + const parsed = parsePath(window.location.pathname); + setView(parsed.view); + setSharedSlug(parsed.sharedSlug); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); - // Scroll to bottom of chat + // Keep the document title and canonical URL in sync with the SPA route so + // content routes (/pricing, /compare) self-canonicalize instead of being + // seen as duplicates of the homepage's hardcoded canonical. + useEffect(() => { + const SITE = 'https://ib-econgraph-ai.vercel.app'; + const meta: Record = { + landing: { title: 'IB EconGraph AI: Free AI-Powered Economics Diagram Editor', path: '/' }, + pricing: { title: 'Pricing · Free Forever · IB EconGraph AI', path: '/pricing' }, + compare: { title: 'How IB EconGraph AI Compares: IB Economics Diagram Tools', path: '/compare' }, + privacy: { title: 'Privacy Policy · IB EconGraph AI', path: '/privacy' }, + terms: { title: 'Terms of Service · IB EconGraph AI', path: '/terms' }, + }; + // App-only views (home/editor/settings/shared) canonicalize to the homepage. + const entry = meta[view] ?? { title: 'IB EconGraph AI: Free Economics Diagram Editor', path: '/' }; + document.title = entry.title; + let link = document.querySelector('link[rel="canonical"]'); + if (!link) { + link = document.createElement('link'); + link.rel = 'canonical'; + document.head.appendChild(link); + } + link.href = SITE + entry.path; + }, [view]); + + // Scroll the chat to the bottom when a message is added to the open graph (or + // when switching graphs), not on every diagram edit, which also mutates + // `graphs` but leaves the message list unchanged. + const activeMessageCount = graphs.find(g => g.id === activeGraphId)?.messages.length ?? 0; useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [graphs, activeGraphId]); + }, [activeMessageCount, activeGraphId]); // Cleanup on unmount useEffect(() => () => { @@ -444,6 +570,7 @@ export default function App() { danger: true, onConfirm: () => { setGraphs(prev => prev.filter(g => g.id !== graphId)); + recordTombstones('graphs', [graphId]); if (activeGraphId === graphId) { setActiveGraphId(null); navigateToView('home'); @@ -457,6 +584,7 @@ export default function App() { // (Used for bulk delete where HomePage shows the confirmation) const deleteGraphsDirect = useCallback((graphIds: string[]) => { setGraphs(prev => prev.filter(g => !graphIds.includes(g.id))); + recordTombstones('graphs', graphIds); // If active graph is being deleted, go to home if (activeGraphId && graphIds.includes(activeGraphId)) { setActiveGraphId(null); @@ -497,9 +625,10 @@ export default function App() { danger: true, onConfirm: () => { setProjects(prev => prev.filter(p => p.id !== projectId)); + recordTombstones('projects', [projectId]); // Unassign graphs from this project setGraphs(prev => prev.map(g => - g.projectId === projectId ? { ...g, projectId: undefined } : g + g.projectId === projectId ? { ...g, projectId: undefined, lastModified: Date.now() } : g )); setConfirmModal(c => ({ ...c, visible: false })); } @@ -558,9 +687,20 @@ export default function App() { )); }, []); - const handleImportData = useCallback((data: { graphs: Graph[]; projects: Project[]; specialColors?: string[]; standardColors?: string[] }) => { - setGraphs(data.graphs); - setProjects(data.projects); + const handleImportData = useCallback(async (data: { graphs: Graph[]; projects: Project[]; specialColors?: string[]; standardColors?: string[] }) => { + // Import replaces everything, tombstone current items missing from the + // backup so cloud sync propagates the replacement instead of undoing it. + const importedGraphIds = new Set(data.graphs.map(g => g.id)); + const importedProjectIds = new Set(data.projects.map(p => p.id)); + recordTombstones('graphs', graphs.filter(g => !importedGraphIds.has(g.id)).map(g => g.id)); + recordTombstones('projects', projects.filter(p => !importedProjectIds.has(p.id)).map(p => p.id)); + // Restored items must win last-write-wins against any wiped/tombstoned + // remote rows, and must not collide with a stale tombstone of the same id. + const now = Date.now(); + clearTombstones('graphs', data.graphs.map(g => g.id)); + clearTombstones('projects', data.projects.map(p => p.id)); + setGraphs(data.graphs.map(g => ({ ...g, lastModified: now }))); + setProjects(data.projects.map(p => ({ ...p, lastModified: now }))); if (data.specialColors && Array.isArray(data.specialColors) && data.specialColors.length >= 2) { setSpecialColors(data.specialColors); } @@ -569,7 +709,16 @@ export default function App() { } // Reset active graph since the data has changed setActiveGraphId(null); - }, []); + // Cloud rows that live only on another device were never in local `graphs`, + // so the filter above can't tombstone them, without this, the next sync + // pulls them back and the "replace everything" restore silently resurrects + // diagrams the backup was meant to drop. Best-effort: null when offline. + const cloud = await fetchCloudIds(); + if (cloud) { + recordTombstones('graphs', cloud.graphIds.filter(id => !importedGraphIds.has(id))); + recordTombstones('projects', cloud.projectIds.filter(id => !importedProjectIds.has(id))); + } + }, [graphs, projects]); const startFromHome = useCallback((projectId?: string) => { const graphId = createGraph(projectId); @@ -602,17 +751,38 @@ export default function App() { [activeGraph, projects] ); + // Single source of truth for AI-availability gating, shared by the chat + // submit guard and the editor warning banner so the two can't drift. Reads + // getAIProvider()/hasApiKey() fresh each call to reflect the latest settings. + const computeAiGate = useCallback((): AiGate => { + if (getAIProvider() === 'hosted') { + if (!user) return 'hosted-signin'; + if (!isPro) return 'hosted-upgrade'; + return null; + } + return hasApiKey() ? null : 'byok-nokey'; + }, [user, isPro]); + const handleSubmit = useCallback(async (e?: React.FormEvent, customPrompt?: string) => { if (e) e.preventDefault(); const promptText = customPrompt || prompt; if (!promptText.trim() || !activeGraphId) return; - // Check for API key before sending - if (!hasApiKey()) { + // Check the AI provider is usable before sending + const gate = computeAiGate(); + const aiBlockedMessage = + gate === 'hosted-signin' + ? 'Sign in (Settings > Account) to use hosted AI, or switch to a free provider with your own API key.' + : gate === 'hosted-upgrade' + ? 'Hosted AI is part of the Supporter plan. Upgrade on the Pricing page, or use your own free API key in Settings.' + : gate === 'byok-nokey' + ? 'API key not configured. Please add your API key in Settings before using AI features.' + : null; + if (aiBlockedMessage) { const errorMsg: Message = { id: generateId(), role: 'model', - content: "API key not configured. Please add your API key in Settings before using AI features.", + content: aiBlockedMessage, timestamp: Date.now() }; setGraphs(prev => prev.map(g => { @@ -649,11 +819,19 @@ export default function App() { const history = activeGraph?.messages.map(m => `${m.role}: ${m.content}`) || []; const result = await generateDiagramData(promptText, history); + // Only let the AI name the graph while it still has the default title. + // Once the user has renamed it, that name is theirs and a later + // generation must not silently overwrite it. + const userNamed = !!activeGraph + && activeGraph.title.trim() !== '' + && activeGraph.title !== EMPTY_DIAGRAM.title; + const nextDiagram = userNamed ? { ...result, title: activeGraph!.title } : result; + const aiMsg: Message = { id: generateId(), role: 'model', content: `Here is the diagram for "${promptText}". You can drag points to adjust curves or double click labels to edit them.`, - diagramData: result, + diagramData: nextDiagram, timestamp: Date.now() }; @@ -662,15 +840,15 @@ export default function App() { return { ...g, messages: [...g.messages, aiMsg], - diagramData: result, - title: result.title, + diagramData: nextDiagram, + title: nextDiagram.title, lastModified: Date.now() }; } return g; })); - setCurrentDiagram(result); - pushToHistory(result); + setCurrentDiagram(nextDiagram); + pushToHistory(nextDiagram); } catch (err) { const message = err instanceof Error @@ -691,7 +869,7 @@ export default function App() { } finally { setIsLoading(false); } - }, [activeGraphId, activeGraph, prompt, pushToHistory, setGraphs]); + }, [activeGraphId, activeGraph, prompt, pushToHistory, setGraphs, computeAiGate]); const handleNewChat = useCallback(() => { if (!activeGraphId) return; @@ -815,6 +993,14 @@ export default function App() { newData.annotatedPoints = [...newData.annotatedPoints, ...newPoints]; } + if (template.data.textLabels && template.data.textLabels.length > 0) { + const newLabels = template.data.textLabels.map(l => ({ + ...l, + id: `label-${generateId()}` + })); + newData.textLabels = [...(newData.textLabels ?? []), ...newLabels]; + } + handleDataChange(newData); }; @@ -883,11 +1069,74 @@ export default function App() { "Perfect Competition Long Run" ]; + // Provider-aware AI availability (drives the editor warning banner). Uses the + // same computeAiGate() discriminant as the chat submit guard above. + const aiGate = computeAiGate(); + const aiWarning: { title: string; body: React.ReactNode } | null = + aiGate === 'hosted-signin' + ? { + title: 'Sign in to use hosted AI', + body: <>Hosted AI needs an account. Sign in from{' '} + + {' '}or switch to a free provider with your own key. + } + : aiGate === 'hosted-upgrade' + ? { + title: 'Hosted AI is a Supporter feature', + body: <>See the{' '} + + , or keep generating free with your own key in{' '} + . + } + : aiGate === 'byok-nokey' + ? { + title: 'API key not configured', + body: <>Add your API key in{' '} + + {' '}to use AI features. + } + : null; + // --- Render Views --- + if (view === 'shared' && sharedSlug) { + return ( + navigateToView('landing')} + /> + ); + } + + if (view === 'pricing') { + return ( + navigateToView('home')} + onOpenLanding={() => navigateToView('landing')} + onOpenCompare={() => navigateToView('compare')} + onOpenSettings={() => navigateToView('settings')} + /> + ); + } + + if (view === 'compare') { + return ( + navigateToView('home')} + onOpenLanding={() => navigateToView('landing')} + onOpenPricing={() => navigateToView('pricing')} + /> + ); + } + + if (view === 'privacy') return ; + if (view === 'terms') return ; + if (view === 'landing') { return ( navigateToView('home')} + onOpenPricing={() => navigateToView('pricing')} + onOpenCompare={() => navigateToView('compare')} /> ); } @@ -938,6 +1187,9 @@ export default function App() { graphs={graphs} projects={projects} onImportData={handleImportData} + syncState={syncState} + onSyncNow={syncNow} + onOpenPricing={() => navigateToView('pricing')} /> ); } @@ -982,6 +1234,23 @@ export default function App() { title={currentDiagram.title} description={currentDiagram.summary} /> + setShareModalOpen(false)} + graph={activeGraph} + onOpenSettings={() => navigateToView('settings')} + onOpenPricing={() => navigateToView('pricing')} + /> + setCloudHistoryOpen(false)} + graph={activeGraph} + onRestore={(diagramData) => { + setCurrentDiagram(diagramData); + pushToHistory(diagramData); + scheduleAutosave(diagramData); + }} + />
@@ -1091,6 +1360,25 @@ export default function App() {
+ {cloudConfigured && ( + <> + + + + )} {' '} - to use AI features. -

+

{aiWarning.title}

+

{aiWarning.body}

)} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2011da1..7ba0c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,66 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] - 2025-02-07 +## [1.1.0] - 2026-07-27 + +### Added + +- **Supporter plan** ($5/mo or $50/yr via Polar, merchant of record) with a + public free-forever guarantee: everything a student needs for their IA stays + free, unlimited, and watermark-free +- Accounts (email + password with one-time verification, or Google) via Supabase, + optional and only needed for cloud features +- **Hosted AI** provider: server-side Gemini generation with no API key setup, + metered at 150 generations/month per Supporter (BYOK stays unlimited & free). + Three interchangeable backends, first configured wins: Vertex AI express key, + Vertex AI with a project (ADC locally, service account on Vercel), or a + Google AI Studio key +- **Account deletion** (`/api/delete-account`): permanently removes the account + and all cloud data, cancelling any active subscription first so a deleted + account can never keep being billed +- **Privacy Policy** (`/privacy`) and **Terms of Service** (`/terms`) pages, + governed by Finnish law and preserving EU/EEA consumer rights +- **Database keepalive workflow** (`.github/workflows/db-keepalive.yml`): a cheap + read every ~5 days so a free-tier Supabase project never pauses after 7 days + of inactivity +- **Cloud sync** across devices: local-first, last-write-wins with deletion + tombstones, plus automatic version history (restorable from the editor) +- **Shareable view-only links** for graphs and projects (`/s/:slug`), revocable, + never including chat history +- **Custom template library**: save your own curve setups, synced to your account +- Pricing page (`/pricing`) and fact-checked comparison page (`/compare`) +- 12 prerendered SEO landing pages (`/diagrams/*`) with IB-specific content, + generated at build time along with the sitemap +- Supporter recognition: opt-in name listing in the README +- Backend setup guide (`docs/BACKEND_SETUP.md`): all cloud features degrade + gracefully when unconfigured, so forks stay zero-config + +### Changed + +- **Relicensed from MIT to AGPL-3.0.** Running a modified version as a network + service now requires publishing the modified source to its users. The project + name, logo, and branding are reserved separately and are not covered by the + code license, so forks should run under their own branding +- Source-code offer linked from Settings, as required by AGPL-3.0 section 13 +- Landing page: pricing/compare navigation, free-forever guarantee messaging, + support/sponsor links +- Settings: new Account & Cloud section (plan status, hosted AI usage meter, + sync controls, supporter preferences) +- Component templates now support text labels +- Renewal handling: entitlement is cushioned by a 1-day margin at the billing + boundary and is never moved backward by a delayed or out-of-order webhook, + while cancellation still ends access immediately +- Import/restore now asks for confirmation before overwriting existing data +- Em dashes and arrow glyphs removed from user-visible text throughout + +### Security + +- Version history is now capped in the database itself. `prune_graph_versions` + clamps its caller-supplied keep count, and an insert trigger enforces a hard + ceiling per graph, so a tampered client cannot grow `graph_versions` without + bound by requesting a huge count or skipping the prune call entirely + +## [1.0.0] - 2026-02-07 ### Added @@ -24,4 +83,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Box select and eraser tools - Pan and zoom controls +[1.1.0]: https://github.com/sukarth/IB-EconGraph-AI/releases/tag/v1.1.0 [1.0.0]: https://github.com/sukarth/IB-EconGraph-AI/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 88aacfd..9385f77 100644 --- a/README.md +++ b/README.md @@ -14,24 +14,46 @@ IB EconGraph AI is a web-based diagram editor designed specifically for the IB E ## Features -- **AI-Powered Generation** — Describe any economics concept in plain English and get an accurate, labeled diagram generated by Google Gemini AI. -- **Manual Drawing Tools** — Draw curves, lines, points, and shapes with precision. Drag entire curves or individual control points. -- **Component Library** — 15+ pre-built templates including Supply & Demand, Monopoly, Tax Incidence, Negative Externalities, and more. -- **Area Shading** — Shade consumer surplus, producer surplus, deadweight/welfare loss, tax revenue, and other regions. -- **Smart Snapping** — Snap to grid and existing points for pixel-perfect alignment. -- **Project Organization** — Organize diagrams into projects. Search, rename, and manage your work. -- **Export** — Export diagrams as high-quality SVG files or PNG and JPEG images. -- **Import/Export Data** — Back up and restore all your graphs, projects, and color palettes as JSON. -- **Customizable Colors** — Full color palette with custom color support. -- **Keyboard Shortcuts** — Undo/redo (Ctrl+Z/Y), tool switching (S, B, L, C, P, T, F, E, H), and more. +- **AI-Powered Generation:** Describe any economics concept in plain English and get an accurate, labeled diagram generated by Google Gemini AI. +- **Manual Drawing Tools:** Draw curves, lines, points, and shapes with precision. Drag entire curves or individual control points. +- **Component Library:** 15+ pre-built templates including Supply & Demand, Monopoly, Tax Incidence, Negative Externalities, and more. +- **Area Shading:** Shade consumer surplus, producer surplus, deadweight/welfare loss, tax revenue, and other regions. +- **Smart Snapping:** Snap to grid and existing points for pixel-perfect alignment. +- **Project Organization:** Organize diagrams into projects. Search, rename, and manage your work. +- **Export:** Export diagrams as high-quality SVG files or PNG and JPEG images. +- **Import/Export Data:** Back up and restore all your graphs, projects, and color palettes as JSON. +- **Customizable Colors:** Full color palette with custom color support. +- **Keyboard Shortcuts:** Undo/redo (Ctrl+Z/Y), tool switching (S, B, L, C, P, T, F, E, H), and more. +- **Cloud Sync** *(Supporter)*: Diagrams synced across devices with version history. +- **Shareable Links** *(Supporter)*: Send a view-only link of a diagram or project to a teacher or group partner. +- **Hosted AI** *(Supporter)*: AI generation with no API key setup, 150 generations/month included. +- **Custom Templates** *(Supporter)*: Save your own curve setups as reusable, synced templates. + +## Free forever: the guarantee + +> **Everything a student needs to finish their IA is free and unlimited, forever.** + +That means unlimited diagrams and projects, every drawing tool and template, all +export formats at full quality with **no watermark**, **unlimited AI generation +with your own free API key (BYOK)**, and local JSON backup/restore. None of this +will ever move behind a paywall. + +The optional **Supporter plan** ($5/month or $50/year) adds hosted conveniences +(hosted AI without API keys, cloud sync with version history, share links, synced +custom templates) and keeps the project alive. See the +[pricing page](https://ib-econgraph-ai.vercel.app/pricing) and +[how we compare](https://ib-econgraph-ai.vercel.app/compare) to other tools. ## Tech Stack - **React 19** with TypeScript - **Vite** for build tooling - **Tailwind CSS** for styling -- **Google Gemini AI** (2.5 Flash) for diagram generation +- **Google Gemini AI** / **OpenRouter** (BYOK) for diagram generation - **Lucide React** for icons +- **Supabase** (auth + Postgres + RLS) for optional accounts & cloud sync +- **Polar** (merchant of record) for optional Supporter subscriptions +- **Vercel** serverless functions for hosted AI & billing endpoints ## Usage @@ -60,7 +82,13 @@ npm install ### Configuration -Enter your API key in the app's Settings page after launching. +Enter your API key in the app's Settings page after launching. No `.env` file is +needed for the core app. + +To self-host the optional cloud features (accounts, sync, Supporter billing, +hosted AI), see [docs/BACKEND_SETUP.md](docs/BACKEND_SETUP.md) and +[.env.example](.env.example). Everything degrades gracefully when unconfigured, +so a fork with no backend keys is simply the full free/local app. ### Development @@ -68,7 +96,7 @@ Enter your API key in the app's Settings page after launching. npm run dev ``` -Opens site at [http://localhost:4000](http://localhost:4000). +Opens the site at [http://localhost:4000](http://localhost:4000). ### Production Build @@ -82,24 +110,55 @@ To serve the production build locally. ## Project Structure ``` -├── App.tsx # Main application component +├── App.tsx # Main application component + routing ├── index.html # HTML entry point with SEO meta tags ├── index.tsx # React DOM initialization -├── types.ts # TypeScript type definitions -├── paletteTypes.ts # Color palette types -├── vite.config.ts # Vite configuration +├── types.ts / paletteTypes.ts # TypeScript type definitions +├── vite.config.ts # Vite config + dev-only API function shim +├── vercel.json # Routing/rewrites for the deployment +├── .env.example # Every supported environment variable +├── .github/workflows/ +│ ├── db-keepalive.yml # Pings the DB so a free project never pauses +│ └── update-supporters.yml # Weekly README supporters refresh +├── api/ # Vercel serverless functions +│ ├── generate.ts # Hosted AI generation (metered, Supporter) +│ ├── usage.ts # Hosted AI usage meter +│ ├── checkout.ts / portal.ts # Polar billing +│ ├── delete-account.ts # Account + data deletion (cancels billing first) +│ ├── webhooks/polar.ts # Subscription state webhook +│ └── _lib/ # Server-only helpers (Supabase admin, Polar) +├── supabase/schema.sql # Database schema + RLS policies +├── docs/BACKEND_SETUP.md # Cloud/billing self-hosting guide +├── scripts/ +│ ├── generate-seo-pages.mjs # Build-time static SEO pages + sitemap +│ ├── seo-content.mjs # Per-diagram-type page content +│ └── update-supporters.mjs # README supporters list updater ├── components/ │ ├── LandingPage.tsx # Marketing/landing page +│ ├── PricingPage.tsx # Free-forever guarantee + Supporter plan +│ ├── ComparePage.tsx # Comparison vs other econ diagram tools +│ ├── LegalPages.tsx # Privacy Policy + Terms of Service │ ├── HomePage.tsx # Dashboard with graph management -│ ├── SettingsPage.tsx # API key and data management +│ ├── SettingsPage.tsx # API keys, account, sync, data management +│ ├── SharedViewPage.tsx # Public read-only share viewer (/s/:slug) │ ├── DiagramRenderer.tsx # SVG canvas and drawing engine -│ ├── ComponentLibrary.tsx # Pre-built diagram templates +│ ├── ComponentLibrary.tsx # Built-in + custom (synced) templates +│ ├── AccountSection.tsx # Account & Cloud settings card +│ ├── AuthModal.tsx / ShareModal.tsx / CloudHistoryModal.tsx │ ├── ToolbarLeft.tsx # Drawing tools panel │ ├── ToolbarRight.tsx # Utility controls (undo, zoom, export) -│ ├── Modal.tsx # Modal components (prompt, confirm, color picker, export) +│ ├── Modal.tsx # Modal components │ └── usePortalTooltip.tsx # Tooltip hook └── services/ - └── gemini.ts # Google Gemini AI integration + ├── ai.ts / aiProvider.ts # Provider facade (Gemini, OpenRouter, hosted) + ├── gemini.ts / openrouter.ts / hostedAi.ts + ├── diagramPrompt.ts # Shared AI prompt + schema (client & server) + ├── auth.tsx # Auth context (Supabase) + ├── entitlement.ts # Single source of truth for the Pro rule + ├── sync.ts / useCloudSync.ts # Local-first cloud sync engine + ├── shares.ts / customTemplates.ts / billing.ts + ├── cloudErrors.ts / keyObfuscation.ts # Shared helpers + └── supabaseClient.ts ``` ## IB Economics Topics Covered @@ -116,25 +175,48 @@ Contributions are welcome. Please open an issue first to discuss what you'd like 4. Push to the branch (`git push origin feature/my-feature`) 5. Open a Pull Request +By contributing, you agree that your contributions are licensed under the project's AGPL-3.0 license. + ## License -Distributed under the MIT License. See [LICENSE](LICENSE) for details. +Copyright (c) 2025-2026 Sukarth Acharya. + +Distributed under the GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text. + +In plain terms: you are free to use, study, modify, and share this code. If you run a modified version as a network service (for example, a hosted copy other people can use), the AGPL requires you to make your modified source available to those users, under the same license. That keeps the project and its improvements open for everyone. + +### Name and branding + +The AGPL covers the *code*. It does not grant rights to the project's name, logo, or branding: **"IB EconGraph AI"**, the EconGraph name, and the project logo are reserved by the author and are not licensed for reuse. + +You are welcome to fork, self-host, and build on this project. If you publish or operate your own version, please run it under your own name and branding, and keep the attribution and source-code offer that the AGPL requires. Do not present a fork as the official IB EconGraph AI or imply it is endorsed by or affiliated with this project. ## Planned Features Below are some planned features for the future. Feel free to **contribute** or suggest additional features! - Support for more diagram types (e.g. Lorenz curves, IS-LM models) -- Collaborative editing and sharing +- Real-time collaborative editing - Mobile-friendly interface - More AI customization options (e.g. style, complexity) -- Integration with other AI providers (OpenRouter,OpenAI, Anthropic) -- Sign-in and cloud storage for projects - Dark mode +- Classroom plan for teachers (one license, whole class gets Supporter). If + there's demand, [open an issue](https://github.com/sukarth/IB-EconGraph-AI/issues) to register interest. + +## Supporters + +A huge thank-you to the Supporters keeping this project free for every student. +([Become one](https://ib-econgraph-ai.vercel.app/pricing); you can opt in to be listed here from Settings.) + + + +*Become the first. See the [Supporter plan](https://ib-econgraph-ai.vercel.app/pricing).* + + ## Support -If this project saves you time, consider supporting my work — it keeps these tools free, open source, and maintained: +If this project saves you time, consider supporting my work. It keeps these tools free, open source, and maintained: [![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-%E2%9D%A4-EA4AAA?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Sukarth) [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-FF5E5B?logo=kofi&logoColor=white)](https://ko-fi.com/sukarth) diff --git a/api/_lib/polar.ts b/api/_lib/polar.ts new file mode 100644 index 0000000..9f019ab --- /dev/null +++ b/api/_lib/polar.ts @@ -0,0 +1,43 @@ +import { Polar } from '@polar-sh/sdk'; + +let cached: Polar | null = null; + +export function getPolar(): Polar { + if (cached) return cached; + const accessToken = process.env.POLAR_ACCESS_TOKEN; + if (!accessToken) { + throw new Error('Polar is not configured (POLAR_ACCESS_TOKEN).'); + } + cached = new Polar({ + accessToken, + server: process.env.POLAR_SERVER === 'sandbox' ? 'sandbox' : 'production', + }); + return cached; +} + +export function getAppUrl(req: { headers: Record }): string { + const clean = (u: string) => u.replace(/\/$/, ''); + + // On Vercel (production or preview), prefer the configured canonical domain + // so checkout redirects land on the primary URL rather than a *.vercel.app + // alias. VERCEL is set automatically in every Vercel deployment. + if (process.env.VERCEL && process.env.APP_URL) { + return clean(process.env.APP_URL); + } + + // Locally — including when the app is reached through a public dev tunnel + // (devtunnels.ms, ngrok, cloudflared…) — send the user back to the exact + // origin their browser is on. The same-origin POST to /api/checkout carries + // that origin, which is reliable even when a tunnel rewrites the Host header. + const origin = req.headers['origin']; + if (typeof origin === 'string' && origin) return clean(origin); + + // Fallbacks: forwarded/host header, then the configured/canonical URL. + const host = (req.headers['x-forwarded-host'] || req.headers.host) as string | undefined; + if (host) { + const isLocal = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host); + const proto = (req.headers['x-forwarded-proto'] as string | undefined) || (isLocal ? 'http' : 'https'); + return `${proto}://${host}`; + } + return clean(process.env.APP_URL || 'https://ib-econgraph-ai.vercel.app'); +} diff --git a/api/_lib/supabaseAdmin.ts b/api/_lib/supabaseAdmin.ts new file mode 100644 index 0000000..cbec7cb --- /dev/null +++ b/api/_lib/supabaseAdmin.ts @@ -0,0 +1,73 @@ +import { createClient, SupabaseClient, User } from '@supabase/supabase-js'; +import type { VercelRequest } from '@vercel/node'; +import { isProUntilActive } from '../../services/entitlement'; + +let cached: SupabaseClient | null = null; + +/** + * Admin Supabase client (bypasses RLS). Server-side only — never expose the + * SUPABASE_SECRET_KEY to the browser. Uses the Supabase secret key + * (`sb_secret_…`), the modern replacement for the legacy service_role key. + */ +export function getSupabaseAdmin(): SupabaseClient { + if (cached) return cached; + const url = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL; + const key = process.env.SUPABASE_SECRET_KEY; + if (!url || !key) { + throw new Error('Supabase server environment is not configured (SUPABASE_URL / SUPABASE_SECRET_KEY).'); + } + cached = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + return cached; +} + +/** + * Validates the Bearer token from the request and returns the Supabase user, + * or null when missing/invalid. + */ +export async function getUserFromRequest(req: VercelRequest): Promise { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : ''; + if (!token) return null; + + const admin = getSupabaseAdmin(); + const { data, error } = await admin.auth.getUser(token); + if (error || !data?.user) return null; + return data.user; +} + +export interface BillingProfile { + id: string; + email: string | null; + pro_status: string; + pro_until: string | null; + polar_customer_id: string | null; + polar_subscription_id: string | null; +} + +export async function getProfile(userId: string): Promise { + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('profiles') + .select('id, email, pro_status, pro_until, polar_customer_id, polar_subscription_id') + .eq('id', userId) + .maybeSingle(); + if (error) throw new Error(`Failed to load profile: ${error.message}`); + return (data as BillingProfile) ?? null; +} + +/** Monthly hosted-AI generation cap (HOSTED_AI_MONTHLY_LIMIT, default 150). */ +export function hostedMonthlyLimit(): number { + const parsed = Number.parseInt(process.env.HOSTED_AI_MONTHLY_LIMIT || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 150; +} + +export function isProfilePro(profile: BillingProfile | null): boolean { + return isProUntilActive(profile?.pro_until); +} + +/** Current usage month in UTC, e.g. "2026-07". */ +export function currentUsageMonth(): string { + return new Date().toISOString().slice(0, 7); +} diff --git a/api/checkout.ts b/api/checkout.ts new file mode 100644 index 0000000..8ef5aa2 --- /dev/null +++ b/api/checkout.ts @@ -0,0 +1,69 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getUserFromRequest, getProfile, isProfilePro } from './_lib/supabaseAdmin'; +import { getPolar, getAppUrl } from './_lib/polar'; + +// Subscription is live (or in dunning), a new checkout would double-charge. +const ACTIVE_STATUSES = new Set(['active', 'trialing', 'past_due']); + +/** + * Creates a Polar checkout session for the Supporter plan and returns its URL. + * Body: { interval: 'month' | 'year' } + */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('checkout: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Please sign in first.' }); + } + + const interval = (req.body as { interval?: string } | undefined)?.interval === 'year' ? 'year' : 'month'; + const productId = interval === 'year' + ? process.env.POLAR_PRODUCT_ID_YEARLY + : process.env.POLAR_PRODUCT_ID_MONTHLY; + if (!productId) { + return res.status(503).json({ error: 'Billing is not configured on this deployment.' }); + } + + // Don't let an already-subscribed user start a second checkout (Polar would + // create a parallel subscription and double-charge them). A canceled-but-in- + // grace user (pro_until still future, status no longer active) can resubscribe. + let profile; + try { + profile = await getProfile(user.id); + } catch (err) { + console.error('checkout: profile lookup failed', err); + return res.status(503).json({ error: 'Could not verify your account right now. Please try again in a moment.' }); + } + if (profile?.polar_subscription_id && isProfilePro(profile) && ACTIVE_STATUSES.has(profile.pro_status)) { + return res.status(409).json({ + error: 'You already have an active Supporter subscription. Manage it from Settings > Manage billing.', + code: 'already_subscribed', + }); + } + + try { + const polar = getPolar(); + const appUrl = getAppUrl(req); + const checkout = await polar.checkouts.create({ + products: [productId], + successUrl: `${appUrl}/settings?checkout=success`, + externalCustomerId: user.id, + customerEmail: user.email ?? undefined, + metadata: { supabase_user_id: user.id }, + }); + return res.status(200).json({ url: checkout.url }); + } catch (err) { + console.error('checkout: failed to create Polar checkout', err); + return res.status(502).json({ error: 'Could not start checkout. Please try again in a moment.' }); + } +} diff --git a/api/delete-account.ts b/api/delete-account.ts new file mode 100644 index 0000000..62e766b --- /dev/null +++ b/api/delete-account.ts @@ -0,0 +1,82 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getSupabaseAdmin, getUserFromRequest, getProfile } from './_lib/supabaseAdmin'; +import { getPolar } from './_lib/polar'; + +const ACTIVE_STATUSES = new Set(['active', 'trialing', 'past_due']); + +/** + * Permanently deletes the signed-in user's account and all cloud data. + * + * Order matters: we cancel any active Polar subscription FIRST so a deleted + * account can't keep being charged (and if we can't cancel it, we abort rather + * than orphan a paid subscription). Then we delete the auth user, which cascades + * to every table via `on delete cascade` — profiles, projects, graphs, + * graph_versions, templates, shares, ai_usage. + */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('delete-account: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Please sign in first.' }); + } + + const admin = getSupabaseAdmin(); + + // Cancel billing before deleting, so the card is never charged for an + // account that no longer exists. If we can't even read the profile, abort + // rather than delete blind and risk orphaning a paid subscription. + let profile; + try { + profile = await getProfile(user.id); + } catch (err) { + console.error('delete-account: profile lookup failed', err); + return res.status(503).json({ + error: 'Could not verify your billing status right now. Please try again in a moment.', + }); + } + + if (profile?.polar_subscription_id && ACTIVE_STATUSES.has(profile.pro_status)) { + const subId = profile.polar_subscription_id; + try { + await getPolar().subscriptions.revoke({ id: subId }); + } catch (err) { + // The revoke can fail simply because the subscription is already + // inactive on Polar (our pro_status was stale) — in that case there's + // nothing left to cancel, so re-check Polar and only trap the user if + // it's genuinely still active. + let stillActive = true; + try { + const sub = await getPolar().subscriptions.get({ id: subId }); + stillActive = ACTIVE_STATUSES.has(sub.status ?? ''); + } catch { + stillActive = false; // e.g. 404 not found → already gone + } + if (stillActive) { + console.error('delete-account: subscription cancel failed', err); + return res.status(409).json({ + error: 'We couldn\'t cancel your active subscription automatically. Please cancel it in "Manage billing" first, then delete your account.', + code: 'cancel_failed', + }); + } + console.warn('delete-account: revoke failed but subscription is no longer active; proceeding with deletion', err); + } + } + + const { error } = await admin.auth.admin.deleteUser(user.id); + if (error) { + console.error('delete-account: deleteUser failed', error); + return res.status(500).json({ error: 'Could not delete your account. Please try again in a moment.' }); + } + + return res.status(200).json({ deleted: true }); +} diff --git a/api/generate.ts b/api/generate.ts new file mode 100644 index 0000000..efc3878 --- /dev/null +++ b/api/generate.ts @@ -0,0 +1,205 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { GoogleGenAI } from '@google/genai'; +import { + getSupabaseAdmin, + getUserFromRequest, + getProfile, + isProfilePro, + currentUsageMonth, + hostedMonthlyLimit as monthlyLimit, +} from './_lib/supabaseAdmin'; +import { + DIAGRAM_SYSTEM_INSTRUCTION, + GEMINI_DIAGRAM_SCHEMA, + buildHistoryContext, +} from '../services/diagramPrompt'; + +const MAX_PROMPT_CHARS = 4000; +const MAX_HISTORY_ENTRIES = 40; +const MAX_HISTORY_CHARS = 24000; + +type AiConfig = { ai: GoogleGenAI; model: string; mode: string }; + +/** + * Resolve the hosted-AI client from environment. Three supported backends, in + * priority order: + * + * 1. Vertex AI express mode — VERTEX_API_KEY. An API key (no service + * account), so it works anywhere including serverless like Vercel. + * 2. Vertex AI (full) — GOOGLE_CLOUD_PROJECT [+ GOOGLE_CLOUD_LOCATION]. + * Auth via Application Default Credentials locally (`gcloud auth + * application-default login`), or a service-account key placed in + * GOOGLE_SERVICE_ACCOUNT_JSON on hosts without gcloud (e.g. Vercel). + * 3. Gemini Developer API — GEMINI_API_KEY (Google AI Studio). Kept as a + * fallback so existing / fully-free deployments keep working unchanged. + * + * Returns null if none is configured. Note: "Vertex AI" was renamed + * "Gemini Enterprise Agent Platform" in 2026; the SDK flag (vertexai: true) + * is unchanged. + */ +function resolveAiClient(): AiConfig | null { + const model = process.env.HOSTED_AI_MODEL || 'gemini-2.5-flash'; + + const vertexApiKey = process.env.VERTEX_API_KEY; + if (vertexApiKey) { + return { ai: new GoogleGenAI({ vertexai: true, apiKey: vertexApiKey }), model, mode: 'vertex-express' }; + } + + const project = process.env.GOOGLE_CLOUD_PROJECT || process.env.VERTEX_PROJECT_ID; + if (project) { + const location = process.env.GOOGLE_CLOUD_LOCATION || process.env.VERTEX_LOCATION || 'global'; + const opts: ConstructorParameters[0] = { vertexai: true, project, location }; + const saJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON; + if (saJson) { + try { + opts.googleAuthOptions = { credentials: JSON.parse(saJson) }; + } catch { + // Malformed key: fall back to ADC rather than crash. If ADC is + // also absent, the generateContent call will surface the auth + // error and the request is refunded like any upstream failure. + console.error('generate: GOOGLE_SERVICE_ACCOUNT_JSON is not valid JSON; falling back to ADC.'); + } + } + return { ai: new GoogleGenAI(opts), model, mode: 'vertex' }; + } + + const geminiKey = process.env.GEMINI_API_KEY; + if (geminiKey) { + return { ai: new GoogleGenAI({ apiKey: geminiKey }), model, mode: 'ai-studio' }; + } + + return null; +} + +/** + * Hosted AI generation for Supporter (Pro) users. Authenticated via Supabase + * JWT, entitlement-checked, and metered per month. The Gemini API key never + * leaves the server. + */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + const aiConfig = resolveAiClient(); + if (!aiConfig) { + return res.status(503).json({ error: 'Hosted AI is not configured on this deployment.' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('generate: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Please sign in to use hosted AI.' }); + } + + const body = (req.body ?? {}) as { prompt?: unknown; history?: unknown }; + const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''; + if (!prompt) { + return res.status(400).json({ error: 'Missing prompt.' }); + } + if (prompt.length > MAX_PROMPT_CHARS) { + return res.status(400).json({ error: `Prompt is too long (max ${MAX_PROMPT_CHARS} characters).` }); + } + + let history: string[] = []; + if (Array.isArray(body.history)) { + history = body.history + .filter((h): h is string => typeof h === 'string') + // Cap each entry so a single huge string can't blow past the total + // cap and reach the hosted Gemini key. + .map((h) => h.slice(0, MAX_HISTORY_CHARS)) + .slice(-MAX_HISTORY_ENTRIES); + // Drain to the total cap, including down to the final entry. + while (history.join('\n').length > MAX_HISTORY_CHARS && history.length > 0) { + history.shift(); + } + } + + const profile = await getProfile(user.id).catch((err) => { + console.error('generate: profile lookup failed', err); + return null; + }); + if (!isProfilePro(profile)) { + return res.status(402).json({ + error: 'Hosted AI is part of the Supporter plan. You can keep generating for free with your own API key (Settings > AI Provider).', + code: 'not_pro', + }); + } + + const admin = getSupabaseAdmin(); + const month = currentUsageMonth(); + const limit = monthlyLimit(); + + const { data: newCount, error: usageError } = await admin.rpc('increment_ai_usage', { + p_user: user.id, + p_month: month, + p_limit: limit, + }); + if (usageError) { + console.error('generate: usage metering failed', usageError); + return res.status(500).json({ error: 'Usage metering failed. Please try again.' }); + } + if (typeof newCount === 'number' && newCount < 0) { + return res.status(429).json({ + error: `You've used all ${limit} hosted generations for this month. They reset at the start of next month, or add your own free API key in Settings for unlimited generations.`, + code: 'quota_exceeded', + usage: { used: limit, limit }, + }); + } + + let responseText: string; + try { + const { ai, model } = aiConfig; + const response = await ai.models.generateContent({ + model, + contents: `${buildHistoryContext(history)} ${prompt}`, + config: { + systemInstruction: DIAGRAM_SYSTEM_INSTRUCTION, + responseMimeType: 'application/json', + responseSchema: GEMINI_DIAGRAM_SCHEMA, + temperature: 0.2, + }, + }); + responseText = response.text || '{}'; + } catch (err) { + // The upstream call itself failed, no generation was produced (and we + // weren't billed), so it's fair to refund the metered credit. This is + // the ONLY refund path: a response that comes back but fails to parse + // below still counts as a used generation, so it can't be farmed to + // burn the hosted key for free. + console.error('generate: Gemini call failed', err); + await admin + .rpc('refund_ai_usage', { p_user: user.id, p_month: month }) + .then(({ error }) => { + if (error) console.error('generate: refund failed', error); + }); + return res.status(502).json({ error: 'The AI generation failed. Please try again.' }); + } + + try { + const diagram = JSON.parse(responseText); + // An empty/whitespace model response becomes '{}' (line above), which + // parses to {}. A diagram without axes would crash the renderer, so + // reject anything missing the required shape. Not refunded (a produced + // response counts as used), same rationale as the parse-failure path. + if (!diagram || typeof diagram !== 'object' || !diagram.xAxis || !diagram.yAxis) { + console.error('generate: model returned an empty/invalid diagram'); + return res.status(502).json({ error: 'The AI returned an empty result. Please try again.' }); + } + return res.status(200).json({ + diagram, + usage: { used: newCount as number, limit }, + }); + } catch (err) { + // Response was produced (and billed upstream) but wasn't valid JSON. + // Not refunded, see above. Rare in practice given the response schema. + console.error('generate: could not parse model output', err); + return res.status(502).json({ error: 'The AI returned an unexpected format. Please try again.' }); + } +} diff --git a/api/portal.ts b/api/portal.ts new file mode 100644 index 0000000..c075310 --- /dev/null +++ b/api/portal.ts @@ -0,0 +1,38 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getUserFromRequest } from './_lib/supabaseAdmin'; +import { getPolar } from './_lib/polar'; + +/** + * Creates a Polar customer-portal session (manage / cancel subscription, + * download invoices) and returns its URL. + */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('portal: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Please sign in first.' }); + } + + try { + const polar = getPolar(); + const session = await polar.customerSessions.create({ + externalCustomerId: user.id, + }); + return res.status(200).json({ url: session.customerPortalUrl }); + } catch (err) { + console.error('portal: failed to create customer session', err); + return res.status(404).json({ + error: 'No billing account found. If you just subscribed, wait a few seconds and try again.', + }); + } +} diff --git a/api/usage.ts b/api/usage.ts new file mode 100644 index 0000000..6334043 --- /dev/null +++ b/api/usage.ts @@ -0,0 +1,46 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { + getSupabaseAdmin, + getUserFromRequest, + getProfile, + isProfilePro, + currentUsageMonth, + hostedMonthlyLimit as monthlyLimit, +} from './_lib/supabaseAdmin'; + +/** Returns the signed-in user's hosted AI usage for the current month. */ +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + let user; + try { + user = await getUserFromRequest(req); + } catch (err) { + console.error('usage: auth backend error', err); + return res.status(503).json({ error: 'Account service is not configured on this deployment.' }); + } + if (!user) { + return res.status(401).json({ error: 'Not signed in.' }); + } + + const [profile, usageResult] = await Promise.all([ + getProfile(user.id).catch(() => null), + getSupabaseAdmin() + .from('ai_usage') + .select('count') + .eq('user_id', user.id) + .eq('month', currentUsageMonth()) + .maybeSingle(), + ]); + + const used = usageResult.data?.count ?? 0; + return res.status(200).json({ + used, + limit: monthlyLimit(), + month: currentUsageMonth(), + isPro: isProfilePro(profile), + }); +} diff --git a/api/webhooks/polar.ts b/api/webhooks/polar.ts new file mode 100644 index 0000000..812c6fa --- /dev/null +++ b/api/webhooks/polar.ts @@ -0,0 +1,165 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'; +import { getSupabaseAdmin } from '../_lib/supabaseAdmin'; + +// Signature verification requires the raw request body. +export const config = { + api: { bodyParser: false }, +}; + +/** + * Safety margin (in days) added to ACTIVE access so a paying subscriber isn't + * locked out during the brief gap if Polar's renewal webhook lands slightly + * after the period end. + * + * This is NOT post-cancellation grace: when a subscription is canceled/revoked, + * the terminal event runs the non-entitled branch below and sets pro_until to + * `now`, which overrides this margin — so it never grants access after a + * cancellation. It only cushions the renewal boundary for continuing subscribers. + */ +const ACTIVE_MARGIN_DAYS = 1; + +const ENTITLED_STATUSES = new Set(['active', 'trialing', 'past_due']); + +function readRawBody(req: VercelRequest): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +interface SubscriptionLike { + id: string; + status: string; + currentPeriodEnd?: Date | null; + recurringInterval?: string | null; + customerId?: string; + customer?: { id?: string; externalId?: string | null } | null; +} + +async function applySubscriptionState(sub: SubscriptionLike): Promise { + const userId = sub.customer?.externalId; + if (!userId) { + // Checkout created outside the app (no external customer id) — nothing to map to. + console.warn(`polar webhook: subscription ${sub.id} has no external customer id, skipping`); + return; + } + + const admin = getSupabaseAdmin(); + const entitled = ENTITLED_STATUSES.has(sub.status); + + // Read what's currently on file so out-of-order or superseded events for a + // DIFFERENT subscription can't clobber the one the user is actually on + // (e.g. after cancel + resubscribe, a delayed event for the old sub). + const { data: current } = await admin + .from('profiles') + .select('polar_subscription_id, pro_until') + .eq('id', userId) + .maybeSingle(); + const onFile = current?.polar_subscription_id; + const differentSub = !!onFile && onFile !== sub.id; + const DAY_MS = 24 * 60 * 60 * 1000; + const currentEnd = current?.pro_until ? Date.parse(current.pro_until) : 0; + + let proUntil: string; + if (entitled) { + const hasPeriodEnd = sub.currentPeriodEnd instanceof Date && !Number.isNaN(sub.currentPeriodEnd.getTime()); + // A malformed event with no usable period end must not lock out an + // entitled user: fall back to a short provisional window (a later, + // well-formed event corrects it) rather than "now", which reads as expired. + const candidate = hasPeriodEnd + ? sub.currentPeriodEnd!.getTime() + ACTIVE_MARGIN_DAYS * DAY_MS + : Date.now() + 2 * DAY_MS; + if (!hasPeriodEnd) { + console.warn(`polar webhook: entitled event for ${sub.id} has no currentPeriodEnd; using provisional window`); + } + + // A delayed/retried event from a different (older) subscription must not + // shorten access the user has via the current one — only let a different + // subscription take over if it actually extends access. + if (differentSub && candidate <= currentEnd) { + console.log(`polar webhook: ignoring stale entitled event for ${sub.id}; ${onFile} on file runs at least as long`); + return; + } + // Never move a still-entitled user's access backward — a delayed or + // retried event (even for the SAME subscription) can carry an older + // period end than one already applied. + proUntil = new Date(Math.max(candidate, currentEnd)).toISOString(); + } else { + // canceled / revoked / unpaid / incomplete → access ends now, but only + // for the subscription currently on file (never for a stale old one). + if (differentSub) { + console.log(`polar webhook: ignoring ${sub.status} for stale subscription ${sub.id} (current is ${onFile})`); + return; + } + proUntil = new Date().toISOString(); + } + + const { error } = await admin + .from('profiles') + .update({ + pro_status: sub.status, + pro_until: proUntil, + plan_interval: sub.recurringInterval ?? null, + polar_customer_id: sub.customer?.id ?? sub.customerId ?? null, + polar_subscription_id: sub.id, + updated_at: new Date().toISOString(), + }) + .eq('id', userId); + + if (error) { + // Throw so Polar retries the delivery. + throw new Error(`Failed to update profile ${userId}: ${error.message}`); + } + console.log(`polar webhook: ${userId} → status=${sub.status} pro_until=${proUntil}`); +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + const secret = process.env.POLAR_WEBHOOK_SECRET; + if (!secret) { + console.error('polar webhook: POLAR_WEBHOOK_SECRET is not set'); + return res.status(503).json({ error: 'Webhook not configured' }); + } + + let event; + try { + const raw = await readRawBody(req); + event = validateEvent(raw, req.headers as Record, secret); + } catch (err) { + if (err instanceof WebhookVerificationError) { + return res.status(403).json({ error: 'Invalid signature' }); + } + console.error('polar webhook: failed to parse event', err); + return res.status(400).json({ error: 'Invalid payload' }); + } + + try { + switch (event.type) { + case 'subscription.created': + case 'subscription.active': + case 'subscription.updated': + case 'subscription.canceled': + case 'subscription.uncanceled': + case 'subscription.revoked': + case 'subscription.past_due': + await applySubscriptionState(event.data as unknown as SubscriptionLike); + break; + default: + // Ack everything else (order.*, checkout.*, customer.*) — subscription + // events carry all the entitlement state we need. + break; + } + return res.status(202).json({ received: true }); + } catch (err) { + console.error(`polar webhook: handler failed for ${event.type}`, err); + // Non-2xx → Polar retries with backoff. + return res.status(500).json({ error: 'Webhook processing failed' }); + } +} diff --git a/components/AccountSection.tsx b/components/AccountSection.tsx new file mode 100644 index 0000000..7467942 --- /dev/null +++ b/components/AccountSection.tsx @@ -0,0 +1,487 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + User as UserIcon, LogOut, Crown, Cloud, CloudOff, RefreshCw, Check, + Sparkles, ExternalLink, Loader2, Heart, Lock, Trash2, +} from 'lucide-react'; +import { useAuth } from '../services/auth'; +import { openBillingPortal, deleteAccount } from '../services/billing'; +import { fetchHostedUsage, HostedUsage } from '../services/hostedAi'; +import { SyncState } from '../services/useCloudSync'; +import AuthModal from './AuthModal'; + +interface AccountSectionProps { + syncState: SyncState; + onSyncNow: () => void; + onOpenPricing: () => void; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString([], { year: 'numeric', month: 'long', day: 'numeric' }); +} + +function formatSyncTime(ts: number | null): string { + if (!ts) return 'not yet'; + const secs = Math.round((Date.now() - ts) / 1000); + if (secs < 5) return 'just now'; + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins} min ago`; + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +/** + * "Account & Cloud" card for the Settings page: sign-in, plan status, + * hosted AI usage, sync controls, and supporter recognition. + */ +const AccountSection: React.FC = ({ syncState, onSyncNow, onOpenPricing }) => { + const { configured, loading, user, profile, isPro, recoveryMode, signOut, refreshProfile, updateProfile, updatePassword } = useAuth(); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [showPwForm, setShowPwForm] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const [pwBusy, setPwBusy] = useState(false); + const [pwError, setPwError] = useState(null); + const [pwSaved, setPwSaved] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [deleteBusy, setDeleteBusy] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [usage, setUsage] = useState(null); + const [portalLoading, setPortalLoading] = useState(false); + const [portalError, setPortalError] = useState(null); + const [supporterName, setSupporterName] = useState(''); + const [showInSupporters, setShowInSupporters] = useState(false); + const [supporterSaved, setSupporterSaved] = useState(false); + const [checkoutPending, setCheckoutPending] = useState(false); + const [checkoutSuccess, setCheckoutSuccess] = useState(false); + const [checkoutDelayed, setCheckoutDelayed] = useState(false); + const pollRef = useRef(null); + + // Load profile-backed form state + useEffect(() => { + setSupporterName(profile?.supporter_name ?? ''); + setShowInSupporters(profile?.show_in_supporters ?? false); + }, [profile?.supporter_name, profile?.show_in_supporters]); + + // Hosted usage meter + useEffect(() => { + if (user && isPro) { + fetchHostedUsage().then(setUsage); + } else { + setUsage(null); + } + }, [user, isPro]); + + // Checkout return flow: ?checkout=success → poll until webhook lands + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('checkout') !== 'success') return; + setCheckoutPending(true); + // Clean the URL so refreshes don't re-trigger + window.history.replaceState({}, '', window.location.pathname); + }, []); + + useEffect(() => { + if (!checkoutPending) return; + if (isPro) { + setCheckoutPending(false); + setCheckoutDelayed(false); + setCheckoutSuccess(true); + return; + } + let attempts = 0; + pollRef.current = window.setInterval(() => { + attempts += 1; + refreshProfile(); + if (attempts > 20) { + // ~60s with no webhook yet, surface an explicit "taking longer" + // state (with a manual Check button) instead of a stuck spinner. + setCheckoutDelayed(true); + if (pollRef.current) window.clearInterval(pollRef.current); + pollRef.current = null; + } + }, 3000); + return () => { + if (pollRef.current) window.clearInterval(pollRef.current); + pollRef.current = null; + }; + }, [checkoutPending, isPro, refreshProfile]); + + // Auto-dismiss the success banner after a few seconds. + useEffect(() => { + if (!checkoutSuccess) return; + const t = window.setTimeout(() => setCheckoutSuccess(false), 8000); + return () => window.clearTimeout(t); + }, [checkoutSuccess]); + + const handlePortal = useCallback(async () => { + setPortalLoading(true); + setPortalError(null); + const result = await openBillingPortal(); + setPortalLoading(false); + if (result.url) { + window.location.href = result.url; + } else { + setPortalError(result.error ?? 'Could not open the billing portal.'); + } + }, []); + + // A password-reset link lands here in recovery mode, open the form. + useEffect(() => { + if (recoveryMode) { setShowPwForm(true); setPwError(null); } + }, [recoveryMode]); + + const handleSetPassword = useCallback(async () => { + if (newPassword.length < 8) { setPwError('Password must be at least 8 characters.'); return; } + setPwBusy(true); + setPwError(null); + const result = await updatePassword(newPassword); + setPwBusy(false); + if (result.error) { setPwError(result.error); return; } + setNewPassword(''); + setShowPwForm(false); + setPwSaved(true); + setTimeout(() => setPwSaved(false), 2500); + }, [newPassword, updatePassword]); + + const handleDeleteAccount = useCallback(async () => { + setDeleteBusy(true); + setDeleteError(null); + const result = await deleteAccount(); + if (result.error) { + setDeleteBusy(false); + setDeleteError(result.error); + return; + } + // Account is gone, clear the now-invalid session and local caches. + await signOut(); + }, [signOut]); + + const handleSaveSupporter = useCallback(async () => { + const result = await updateProfile({ + supporter_name: supporterName.trim() || null, + show_in_supporters: showInSupporters, + }); + if (!result.error) { + setSupporterSaved(true); + setTimeout(() => setSupporterSaved(false), 2000); + } + }, [supporterName, showInSupporters, updateProfile]); + + if (!configured) return null; + + return ( +
+ setAuthModalOpen(false)} /> + +
+
+
+ +
+
+

Account & Cloud

+

Sync your diagrams across devices, share links, and hosted AI

+
+
+
+ +
+ {checkoutPending && !isPro && !checkoutDelayed && ( +
+ + Payment received, activating your Supporter plan. This usually takes a few seconds. +
+ )} + {checkoutPending && !isPro && checkoutDelayed && ( +
+ + Payment received, activation is taking longer than usual. It will complete + automatically; you can check again or reload this page. + + +
+ )} + {checkoutSuccess && isPro && ( +
+ + You're a Supporter now, thank you! Cloud sync and hosted AI are active. +
+ )} + + {loading ? ( +
+ Loading account… +
+ ) : !user ? ( +
+

+ You're not signed in. Everything you need to finish your IA works without an + account, sign in only if you want cloud sync, + share links, or hosted AI (Supporter plan). +

+
+ + +
+
+ ) : ( + <> + {/* Identity + plan */} +
+
+
{user.email}
+ {isPro ? ( +
+ + Supporter{profile?.plan_interval === 'year' ? ' (yearly)' : profile?.plan_interval === 'month' ? ' (monthly)' : ''} + {profile?.pro_until && · renews/expires {formatDate(profile.pro_until)}} +
+ ) : ( +
Free plan, unlimited local diagrams, BYOK AI, full exports
+ )} +
+
+ {isPro ? ( + + ) : ( + + )} + +
+
+ {portalError && ( +
{portalError}
+ )} + + {/* Hosted AI usage */} + {isPro && usage && ( +
+
+
+ + Hosted AI generations this month +
+ {usage.used} / {usage.limit} +
+
+
0.9 ? 'bg-amber-500' : 'bg-purple-500'}`} + style={{ width: `${Math.min(100, (usage.used / usage.limit) * 100)}%` }} + /> +
+

+ Resets monthly. Your own API key (BYOK) is always unlimited and free. +

+
+ )} + + {/* Sync status */} +
+
+ {isPro ? ( + syncState.status === 'error' + ? + : + ) : ( + + )} +
+
Cloud sync
+
+ {!isPro + ? 'Supporter feature, protects your IA from a cleared browser cache' + : syncState.status === 'syncing' + ? 'Syncing…' + : syncState.status === 'error' + ? (syncState.error ?? 'Sync error') + : syncState.status === 'offline' + ? 'Offline, will retry when back online' + : `Synced ${formatSyncTime(syncState.lastSyncedAt)}`} +
+
+
+ {isPro && ( + + )} +
+ + {/* Password */} +
+
+
+ + Password +
+ {!showPwForm && ( + + )} +
+ {recoveryMode && ( +

+ Choose a new password to finish resetting your account. +

+ )} + {showPwForm && ( +
+ setNewPassword(e.target.value)} + autoComplete="new-password" + placeholder="New password (min 8 characters)" + className="flex-1 min-w-48 px-3 py-2 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> + + {!recoveryMode && ( + + )} +
+ )} + {pwError &&

{pwError}

} +
+ + {/* Supporter recognition */} + {isPro && ( +
+
+ + Supporter recognition +
+

+ Optionally list your name in the project README's supporters section. Leave blank to stay anonymous. +

+
+ setSupporterName(e.target.value)} + maxLength={50} + placeholder="Name to display (optional)" + className="flex-1 min-w-48 px-3 py-2 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> + + +
+
+ )} + + {/* Danger zone, delete account + all cloud data */} +
+
+ + Delete account +
+

+ Permanently deletes your account and all cloud-synced data (projects, graphs, + version history, templates, share links) and cancels any active subscription. + This can't be undone. Diagrams stored locally on this device are not affected. +

+ {!deleteConfirm ? ( + + ) : ( +
+

Are you sure? This is permanent.

+
+ + +
+
+ )} + {deleteError &&

{deleteError}

} +
+ + )} +
+
+ ); +}; + +export default AccountSection; diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx new file mode 100644 index 0000000..2a55b33 --- /dev/null +++ b/components/AuthModal.tsx @@ -0,0 +1,261 @@ +import React, { useState, useEffect } from 'react'; +import { Mail, Lock, Eye, EyeOff, Check, Loader2, LogIn, UserPlus, ArrowLeft } from 'lucide-react'; +import { Modal } from './Modal'; +import { useAuth } from '../services/auth'; + +interface AuthModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; + message?: string; +} + +const MIN_PASSWORD = 8; +type View = 'signin' | 'signup' | 'forgot' | 'confirm-sent' | 'reset-sent'; + +/** + * Sign-in dialog: email + password (with one-time email confirmation on signup) + * and Google OAuth. Creating an account is free, it's the prerequisite for + * checkout and Supporter features. Password login keeps email volume low, which + * matters on Supabase's rate-limited default mailer; Google sends none at all. + */ +export const AuthModal: React.FC = ({ + isOpen, + onClose, + title = 'Sign in', + message, +}) => { + const { signInWithPassword, signUpWithPassword, resetPassword, signInWithGoogle } = useAuth(); + const [view, setView] = useState('signin'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Reset the flow each time the modal opens so a second open never shows a + // stale success/confirmation screen from a previous attempt. + useEffect(() => { + if (isOpen) { + setView('signin'); + setPassword(''); + setShowPassword(false); + setError(null); + } + }, [isOpen]); + + const handleSignIn = async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || !email.trim() || !password) return; + setBusy(true); + setError(null); + const result = await signInWithPassword(email, password); + setBusy(false); + if (result.error) setError(result.error); + else onClose(); + }; + + const handleSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || !email.trim()) return; + if (password.length < MIN_PASSWORD) { + setError(`Password must be at least ${MIN_PASSWORD} characters.`); + return; + } + setBusy(true); + setError(null); + const result = await signUpWithPassword(email, password); + setBusy(false); + if (result.error) setError(result.error); + else if (result.needsConfirmation) setView('confirm-sent'); + else onClose(); // confirmation disabled → signed in immediately + }; + + const handleForgot = async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || !email.trim()) return; + setBusy(true); + setError(null); + const result = await resetPassword(email); + setBusy(false); + if (result.error) setError(result.error); + else setView('reset-sent'); + }; + + const handleGoogle = async () => { + setError(null); + const result = await signInWithGoogle(); + if (result.error) setError(result.error); + }; + + // ---- "email sent" confirmation screens ------------------------------- + if (view === 'confirm-sent' || view === 'reset-sent') { + const isConfirm = view === 'confirm-sent'; + return ( + +
+
+ +
+

Check your inbox

+

+ {isConfirm + ? <>We sent a confirmation link to {email}. Open it to verify your account, then sign in. + : <>We sent a password-reset link to {email}. Open it to choose a new password.} +

+ +
+
+ ); + } + + // ---- forgot-password form -------------------------------------------- + if (view === 'forgot') { + return ( + +
+

+ Enter your email and we'll send you a link to set a new password. +

+
+ + setEmail(e.target.value)} + placeholder="you@school.org" + className="w-full pl-9 pr-3 py-3 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> +
+ + {error &&
{error}
} + +
+
+ ); + } + + // ---- sign-in / sign-up form ------------------------------------------ + const isSignup = view === 'signup'; + return ( + +
+ {message &&

{message}

} + +
+
+ + setEmail(e.target.value)} + placeholder="you@school.org" + className="w-full pl-9 pr-3 py-3 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> +
+
+ + setPassword(e.target.value)} + placeholder={isSignup ? `Password (min ${MIN_PASSWORD} characters)` : 'Password'} + className="w-full pl-9 pr-10 py-3 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50" + /> + +
+ {!isSignup && ( +
+ +
+ )} + +
+ +
+
+ or +
+
+ + + + {error && ( +
{error}
+ )} + +

+ {isSignup ? 'Already have an account?' : "Don't have an account?"}{' '} + +

+ +

+ Accounts are free. You only need one for cloud features, the editor, + exports, and AI with your own key work without signing in. +

+
+ + ); +}; + +export default AuthModal; diff --git a/components/CloudHistoryModal.tsx b/components/CloudHistoryModal.tsx new file mode 100644 index 0000000..5114c75 --- /dev/null +++ b/components/CloudHistoryModal.tsx @@ -0,0 +1,102 @@ +import React, { useState, useEffect } from 'react'; +import { History, Loader2, RotateCcw, CloudOff } from 'lucide-react'; +import { Modal } from './Modal'; +import { useAuth } from '../services/auth'; +import { fetchGraphVersions, CloudVersion } from '../services/customTemplates'; +import { DiagramData, Graph } from '../types'; + +interface CloudHistoryModalProps { + isOpen: boolean; + onClose: () => void; + graph: Graph | null; + onRestore: (diagramData: DiagramData) => void; +} + +function formatWhen(iso: string): string { + const date = new Date(iso); + const today = new Date(); + const sameDay = date.toDateString() === today.toDateString(); + return sameDay + ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +/** + * Cloud version history (Supporter feature). Lists synced snapshots of the + * active graph and restores the diagram content of a chosen version. + */ +export const CloudHistoryModal: React.FC = ({ isOpen, onClose, graph, onRestore }) => { + const { user, isPro } = useAuth(); + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!isOpen || !graph || !user || !isPro) return; + let cancelled = false; + setLoading(true); + fetchGraphVersions(graph.id) + .then((v) => { if (!cancelled) setVersions(v); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [isOpen, graph, user, isPro]); + + const handleRestore = (version: CloudVersion) => { + const data = version.data as Graph | null; + if (data && typeof data === 'object' && data.diagramData) { + onRestore(data.diagramData); + onClose(); + } + }; + + return ( + + {!user || !isPro ? ( +
+ +

Version history is part of the Supporter plan and needs cloud sync to be active.

+
+ ) : loading ? ( +
+ +
+ ) : versions.length === 0 ? ( +
+ +

No cloud versions yet. Versions are saved automatically every time this graph syncs.

+
+ ) : ( +
+

+ Restoring replaces the current diagram (your chat history is kept). You can undo with Ctrl+Z. +

+ {versions.map((version, i) => ( +
+
+ +
+
+
+ {version.title || 'Untitled graph'} + {i === 0 && latest} +
+
{formatWhen(version.createdAt)}
+
+ +
+ ))} +
+ )} +
+ ); +}; + +export default CloudHistoryModal; diff --git a/components/ComparePage.tsx b/components/ComparePage.tsx new file mode 100644 index 0000000..a80d79e --- /dev/null +++ b/components/ComparePage.tsx @@ -0,0 +1,268 @@ +import React from 'react'; +import { + BarChart2, Check, X, Minus, Github, ArrowRight, ShieldCheck, Info, +} from 'lucide-react'; + +interface ComparePageProps { + onOpenEditor: () => void; + onOpenLanding: () => void; + onOpenPricing: () => void; +} + +type CellValue = { kind: 'yes' | 'no' | 'partial'; text: string }; + +const yes = (text: string): CellValue => ({ kind: 'yes', text }); +const no = (text: string): CellValue => ({ kind: 'no', text }); +const partial = (text: string): CellValue => ({ kind: 'partial', text }); + +// Competitor facts verified against their live sites on 2026-07-17. +// EconGraph Pro: econgraphs.diplomacollective.com (Diploma Collective) +// EconDiagrams: econdiagrams.com (EconDaddy.com Ltd., in beta) +const ROWS: { label: string; us: CellValue; egp: CellValue; ed: CellValue }[] = [ + { + label: 'Price to create & export a diagram', + us: yes('Free, forever, no watermark'), + egp: no('Paid membership required to download or save ($2/mo at checkout; their site also shows $1.66/mo)'), + ed: partial('Free tier exports images, capped at 3 diagrams'), + }, + { + label: 'Diagram limit on the free tier', + us: yes('Unlimited diagrams & projects'), + egp: no('None savable, downloads and saving are fully paywalled'), + ed: no('3 diagrams, 1 whiteboard, 1 collection'), + }, + { + label: 'AI diagram generation', + us: yes('Yes, free with your own key, or hosted on the Supporter plan'), + egp: no('No AI features'), + ed: no('No AI features'), + }, + { + label: 'Export formats', + us: yes('SVG, PNG, and JPEG at full quality'), + egp: partial('Single "Download Diagram" button, behind the paywall (formats unverified)'), + ed: partial('"Export as image" (format unspecified)'), + }, + { + label: 'Works without an account', + us: yes('Yes, no sign-up to create or export'), + egp: partial('Can edit without an account, but paid account needed to download'), + ed: no('Email registration required'), + }, + { + label: 'Diagram coverage', + us: yes('Any IB diagram, freeform tools, 15+ templates, and AI for the rest'), + egp: no('5 diagram types live (a 6th marked "Coming Soon")'), + ed: yes('40+ IB-aligned templates claimed (site also says 50+)'), + }, + { + label: 'Your data stays on your device', + us: yes('Local-first, cloud sync is optional'), + egp: no('Cloud-based'), + ed: no('Cloud-based'), + }, + { + label: 'Open source', + us: yes('AGPL-3.0 licensed, audit it, fork it, self-host it'), + egp: no('Proprietary'), + ed: no('Proprietary'), + }, + { + label: 'Product status', + us: yes('Live and actively maintained'), + egp: yes('Live'), + ed: partial('Public beta (paid plan invite-only, unpriced)'), + }, +]; + +const CellIcon: React.FC<{ kind: CellValue['kind'] }> = ({ kind }) => { + if (kind === 'yes') { + return ( +
+ +
+ ); + } + if (kind === 'no') { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); +}; + +const ComparePage: React.FC = ({ onOpenEditor, onOpenLanding, onOpenPricing }) => { + return ( +
+ {/* Nav */} + + + {/* Hero */} +
+
+
+ + An honest comparison +
+

+ How IB EconGraph AI compares +

+

+ The two tools IB Economics students most often consider are{' '} + EconGraph Pro (Diploma Collective) and{' '} + EconDiagrams (EconDaddy). Here's the honest, factual breakdown. +

+
+
+ + {/* Comparison table */} +
+
+
+ + + + + + + + + + + {ROWS.map((row, i) => ( + + + + + + + ))} + +
+ IB EconGraph AI +
this tool
+
+ EconGraph Pro +
Diploma Collective
+
+ EconDiagrams +
EconDaddy · beta
+
{row.label} +
+ + {row.us.text} +
+
+
+ + {row.egp.text} +
+
+
+ + {row.ed.text} +
+
+
+ +
+ +

+ Based on each product's publicly visible website and app as of July 17, 2026. Details behind + paywalls or logins are marked unverified. Products may change, so check their sites for current + terms. Spotted an inaccuracy?{' '} + + Open an issue + {' '} + and it will be corrected. +

+
+
+
+ + {/* CTA */} +
+
+

+ Try it now! +

+ You got nothing to lose, literally +

+

+ No account, no card, no watermark. Your first exam-ready diagram is 30 seconds away. +

+
+ + +
+
+
+ + {/* Footer */} +
+
+ +
+ + + GitHub + + AGPL-3.0 +
+
+
+
+ ); +}; + +export default ComparePage; diff --git a/components/ComponentLibrary.tsx b/components/ComponentLibrary.tsx index b04da24..389c04a 100644 --- a/components/ComponentLibrary.tsx +++ b/components/ComponentLibrary.tsx @@ -1,16 +1,23 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { TrendingDown, TrendingUp, Activity, Minus, ArrowDownRight, ArrowUp, Triangle, AlertTriangle, Square, Target, Circle, BarChart2, Crown, Receipt, - ChevronDown, ChevronRight, Search, Plus, X, Package + ChevronDown, ChevronRight, Search, Plus, X, Package, Star, Trash2, Loader2, BookmarkPlus } from 'lucide-react'; -import { ComponentTemplate, COMPONENT_TEMPLATES } from '../types'; +import { ComponentTemplate, COMPONENT_TEMPLATES, DiagramData } from '../types'; +import { ConfirmModal } from './Modal'; import { usePortalTooltip } from './usePortalTooltip'; +import { useAuth } from '../services/auth'; +import { + CustomTemplate, listCachedTemplates, fetchCustomTemplates, + saveCustomTemplate, deleteCustomTemplate, templateDataFromDiagram, +} from '../services/customTemplates'; export interface ComponentLibraryProps { onAddComponent: (template: ComponentTemplate) => void; isOpen: boolean; onClose: () => void; + currentDiagram: DiagramData; } const iconMap: Record = { @@ -41,14 +48,94 @@ const ComponentLibrary: React.FC = ({ onAddComponent, isOpen, onClose, + currentDiagram, }) => { const [searchTerm, setSearchTerm] = useState(''); - const [expandedCategories, setExpandedCategories] = useState(['curves', 'areas', 'points', 'complete']); + const [expandedCategories, setExpandedCategories] = useState(['custom', 'curves', 'areas', 'points', 'complete']); const { showTooltip, hideTooltip, TooltipPortal } = usePortalTooltip({ delay: 400, placement: 'left' }); + // ── Custom templates (Supporter feature, synced) ── + const { configured: cloudConfigured, user, isPro } = useAuth(); + const [customTemplates, setCustomTemplates] = useState([]); + const [showSaveForm, setShowSaveForm] = useState(false); + const [saveName, setSaveName] = useState(''); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + // Template awaiting delete confirmation (null when the dialog is closed). + const [pendingDelete, setPendingDelete] = useState(null); + + // Only ever show templates belonging to the signed-in user; clear when + // signed out so a previous user's cache never leaks on a shared browser. + useEffect(() => { + if (!user) { + setCustomTemplates([]); + return; + } + setCustomTemplates(listCachedTemplates(user.id)); + if (isOpen && isPro) { + fetchCustomTemplates(user.id).then(setCustomTemplates); + } + }, [isOpen, user, isPro]); + if (!isOpen) return null; + const handleSaveTemplate = async () => { + if (!user) { + setSaveError('Sign in (Settings) to save templates.'); + return; + } + setSaving(true); + setSaveError(null); + const result = await saveCustomTemplate(user.id, { + name: saveName, + data: templateDataFromDiagram(currentDiagram), + }); + setSaving(false); + if (result.error) { + setSaveError(result.error); + } else if (result.template) { + setCustomTemplates(prev => [result.template!, ...prev]); + setShowSaveForm(false); + setSaveName(''); + } + }; + + const handleDeleteTemplate = async (id: string) => { + if (!user) return; + const prevList = customTemplates; + setCustomTemplates(prev => prev.filter(t => t.id !== id)); + const { error } = await deleteCustomTemplate(user.id, id); + if (error) { + setCustomTemplates(prevList); // roll back the optimistic removal + setSaveError(error); + } + }; + + // Deleting a synced template removes it from every device, so confirm first + // (same pattern as the destructive actions in Settings). + const confirmDeleteTemplate = () => { + if (!pendingDelete) return; + handleDeleteTemplate(pendingDelete.id); + setPendingDelete(null); + }; + + const addCustomTemplate = (t: CustomTemplate) => { + onAddComponent({ + id: t.id, + name: t.name, + description: t.description, + category: 'complete', + icon: 'Star', + data: t.data, + }); + }; + + const filteredCustom = customTemplates.filter( + t => t.name.toLowerCase().includes(searchTerm.toLowerCase()) || + t.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + const toggleCategory = (category: string) => { setExpandedCategories(prev => prev.includes(category) @@ -100,6 +187,101 @@ const ComponentLibrary: React.FC = ({ {/* Component List */}
+ {/* My Templates (Supporter) */} + {cloudConfigured && ( +
+ + + {expandedCategories.includes('custom') && ( +
+ {showSaveForm ? ( +
+ setSaveName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSaveTemplate(); }} + placeholder="Template name…" + autoFocus + className="w-full px-2.5 py-1.5 text-sm border border-gray-200 rounded-md focus:border-indigo-400 focus:ring-1 focus:ring-indigo-100 outline-none" + /> +
+ + +
+
+ ) : ( + + )} + + {/* Errors from save OR delete show regardless of form state */} + {saveError &&

{saveError}

} + + {filteredCustom.map((t) => ( +
addCustomTemplate(t)} + > +
+ +
+
+

{t.name}

+

+ {t.description || new Date(t.createdAt).toLocaleDateString()} +

+
+ +
+ ))} + + {filteredCustom.length === 0 && !showSaveForm && ( +

+ {user && isPro + ? 'No templates yet, save your favourite curve setups.' + : 'Save & sync your own templates with the Supporter plan.'} +

+ )} +
+ )} +
+ )} + {Object.entries(categoryLabels).map(([category, { label, color }]) => { const templates = groupedTemplates[category]; if (!templates || templates.length === 0) return null; @@ -153,6 +335,16 @@ const ComponentLibrary: React.FC = ({

+ + setPendingDelete(null)} + onConfirm={confirmDeleteTemplate} + title="Delete Template" + message={`Delete the template "${pendingDelete?.name ?? ''}"? It will be removed from your library on all your devices. This can't be undone.`} + confirmText="Delete" + variant="danger" + />
); }; diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index ad1a044..e54e2bc 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -20,6 +20,8 @@ import { export interface LandingPageProps { onGoHome: () => void; + onOpenPricing: () => void; + onOpenCompare: () => void; } // ─── Fade-in on scroll component ─── @@ -71,7 +73,7 @@ const ScrollReveal: React.FC<{ ); }; -const LandingPage: React.FC = ({ onGoHome }) => { +const LandingPage: React.FC = ({ onGoHome, onOpenPricing, onOpenCompare }) => { const [scrollY, setScrollY] = useState(0); const [heroTilt, setHeroTilt] = useState({ rotateX: 0, rotateY: 0 }); const [openSourceMouse, setOpenSourceMouse] = useState({ x: 50, y: 50 }); @@ -194,6 +196,18 @@ const LandingPage: React.FC = ({ onGoHome }) => {
+ + = ({ onGoHome }) => {
-

+

- Free & open source forever. No account required. + Everything a student needs to finish their IA is free and unlimited, forever.

@@ -396,7 +410,7 @@ const LandingPage: React.FC = ({ onGoHome }) => { Community Driven.

- IB EconGraph AI is fully open source under the MIT License. Inspect the code, + IB EconGraph AI is fully open source under the GNU AGPL v3. Inspect the code, contribute features, report bugs, or fork it for your own needs. Built by students, for students.

@@ -419,7 +433,23 @@ const LandingPage: React.FC = ({ onGoHome }) => { Star the Repo
+ + + Support the Project + +

+ Sponsorships and the{' '} + {' '} + keep this tool free for every student. Thank you. +

@@ -427,7 +457,7 @@ const LandingPage: React.FC = ({ onGoHome }) => {
Free
-
MIT
+
AGPL
License
@@ -623,7 +653,7 @@ const LandingPage: React.FC = ({ onGoHome }) => {

Whether you're preparing for Paper 1, working on your Internal Assessment, - or studying for exams — IB EconGraph AI helps you create the exact diagrams + or studying for exams, IB EconGraph AI helps you create the exact diagrams your IB Economics course demands, from microeconomics to international trade.

@@ -643,7 +673,7 @@ const LandingPage: React.FC = ({ onGoHome }) => { { icon: , title: 'Full IB Curriculum', - desc: 'Covers all IB Economics topics — micro, macro, international, and development economics.', + desc: 'Covers all the IB Economics topics: micro, macro and international economics.', color: 'text-amber-600 bg-amber-100', }, ].map((item, i) => ( @@ -704,7 +734,40 @@ const LandingPage: React.FC = ({ onGoHome }) => {

Free & open source. Built for IB Economics students and educators.

-
+
+ + + + Privacy + + + Terms + + + + Support + = ({ onGoHome }) => { GitHub | - MIT License + AGPL-3.0
diff --git a/components/LegalPages.tsx b/components/LegalPages.tsx new file mode 100644 index 0000000..9cc89d7 --- /dev/null +++ b/components/LegalPages.tsx @@ -0,0 +1,254 @@ +import React from 'react'; + +const SITE = 'https://ib-econgraph-ai.vercel.app'; +const REPO = 'https://github.com/sukarth/IB-EconGraph-AI'; +const CONTACT_EMAIL = 'sukarth.dev@gmail.com'; +const LAST_UPDATED = '19 July 2026'; + +/** Inline chevron used in place of a literal arrow character in nav breadcrumbs. */ +const Arrow: React.FC = () => ( + +); + +const LegalLayout: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+ +
+

{title}

+

Last updated: {LAST_UPDATED}

+
{children}
+ +
+
+); + +const H2: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +

{children}

+); +const P: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +

{children}

+); +const LI: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
  • {children}
  • +); + +export const PrivacyPage: React.FC = () => ( + +
    +

    + IB EconGraph AI ("the Service", "we", "us") is a free, open-source diagram editor for + IB Economics students and teachers. This policy explains what data we handle and why. + The Service works fully offline in your browser without an account. The data below is + only involved if you choose to create an account or use the optional Supporter features. +

    +
    + +
    +

    What we collect

    +
      +
    • Account details. If you sign up, we store your email address and, via our + authentication provider, a securely hashed password. Google sign-in shares your email and + basic profile. You may optionally add a display name or a "supporter name".
    • +
    • Synced content (Supporter plan). If you turn on cloud sync, your diagrams, + projects, custom templates, version history and share links are stored on our servers so you + can access them across devices.
    • +
    • Hosted AI prompts (Supporter plan). When you use hosted AI generation, the + text prompt you submit is sent to Google's Gemini models to produce a diagram. We meter the + number of generations per month but do not use your prompts to train any model.
    • +
    • Billing data. Payments are processed by Polar as merchant of record. We never + receive or store your full card details. We store a Polar customer/subscription identifier and + your subscription status so we can grant Supporter access.
    • +
    • Local-only data. Diagrams you create without sync, and any AI API keys you + enter yourself (BYOK), stay in your browser's local storage and are never sent to us.
    • +
    +
    + +
    +

    How we use it

    +

    + We use this data only to provide the Service: to authenticate you, sync and back up your work, + deliver hosted AI, process your subscription, and credit supporters who opt in. We do not sell + your data, and we do not run third-party advertising trackers. +

    +
    + +
    +

    Service providers

    +

    We rely on a small number of processors, each handling only what their function needs:

    +
      +
    • Supabase: authentication and database (your account and synced content).
    • +
    • Polar: subscription billing and payment processing (merchant of record).
    • +
    • Google (Gemini models, via Vertex AI or the Gemini API): processes hosted AI prompts to generate diagrams.
    • +
    • Vercel: application hosting and content delivery.
    • +
    +
    + +
    +

    Data retention & deletion

    +

    + We keep your account data until you delete it. You can permanently delete your account and all + cloud-synced data at any time from Settings Account & Cloud Delete account, + which also cancels any active subscription. You can export a full copy of your data at any time + from Settings Import & Export. To make a request by email, contact us at + the address below. +

    +
    + +
    +

    Your rights

    +

    + Depending on where you live (for example under the EU/UK GDPR), you have rights to access, correct, + export, and delete your personal data, and to object to certain processing. The in-app export and + delete tools cover most of these directly; for anything else, email us and we'll help. +

    +
    + +
    +

    Children & students

    +

    + The Service is aimed at IB Economics students, some of whom are minors. We only collect the + minimal account data described above. If you are below the age of digital consent in your country + (for example under 16 in parts of the EU, or under 13 in the US), please use the Service with a + parent's or guardian's permission, and have them create or approve any account. If you believe a + child has given us personal data without appropriate consent, contact us and we will delete it. +

    +
    + +
    +

    Security & international transfer

    +

    + Data is transmitted over encrypted connections (HTTPS) and protected by row-level security so each + account can only access its own records. Our providers may process data in the EU and the US; + where required, they rely on appropriate safeguards for international transfers. +

    +
    + +
    +

    Changes & contact

    +

    + We may update this policy as the Service evolves, and we'll revise the "last updated" date above. + Questions or requests: email {CONTACT_EMAIL}{' '} + or open an issue on GitHub. +

    +
    +
    +); + +export const TermsPage: React.FC = () => ( + +
    +

    + These terms govern your use of IB EconGraph AI ("the Service"). By using the Service you agree to + them. If you don't agree, please don't use the Service. +

    +
    + +
    +

    The Service

    +

    + IB EconGraph AI is a diagram editor for IB Economics. The core editor is free to use, and we + intend to keep it that way: unlimited diagrams and projects, every drawing tool and template, + full-quality exports with no watermark, and AI generation using your own API key. We won't + retroactively paywall diagrams you've already made or your ability to export them. The optional + Supporter plan adds hosted conveniences (hosted AI, cloud sync, version history, + share links, synced templates). +

    +
    + +
    +

    Accounts

    +

    + You need an account only for Supporter features. Provide accurate information, keep your password + secure, and you're responsible for activity under your account. You can delete your account at any + time from Settings. +

    +
    + +
    +

    Subscriptions, billing & cancellation

    +
      +
    • The Supporter plan is $5/month or $50/year, billed through Polar, our merchant of record, + which also handles applicable taxes (e.g. VAT).
    • +
    • Subscriptions renew automatically each period until cancelled.
    • +
    • You can cancel any time via Manage billing in Settings. Access continues + until the end of the period you've already paid for, after which it ends.
    • +
    • Except where required by law (for example EU/UK withdrawal rights, handled through Polar), + payments are non-refundable. Deleting your account cancels the subscription.
    • +
    +
    + +
    +

    Hosted AI & fair use

    +

    + Hosted AI generation is included with the Supporter plan up to a monthly limit (currently 150 + generations). It's for normal, personal use in creating economics diagrams. Automated abuse, + reselling, or attempts to extract or overuse the underlying AI service may be rate-limited or + suspended. You can always switch to your own free API key for unlimited generations. +

    +
    + +
    +

    Acceptable use

    +

    + Don't use the Service for anything unlawful, don't attempt to break its security or access other + users' data, and don't misuse the AI features. We may suspend accounts that do. +

    +
    + +
    +

    Your content & our code

    +

    + Your diagrams and projects are yours. The application's source code is open source under the GNU + Affero General Public License v3.0 (AGPL-3.0); see our repository for the full text. You grant us + only the limited permission needed to store and sync your content so we can provide the Service. +

    +
    + +
    +

    Disclaimer & liability

    +

    + The Service is provided "as is", without warranties of any kind. It's an educational tool; + AI-generated diagrams may contain mistakes, and you're responsible for checking your work. We + don't guarantee exam accuracy or results. To the fullest extent permitted by law, we are not + liable for indirect or consequential damages, and our total liability is limited to the amount you + paid us in the past 12 months. +

    +
    + +
    +

    Changes, termination & contact

    +

    + We may update these terms or the Service; material changes will be reflected in the "last updated" + date. We may suspend or end access for violations of these terms. These terms are governed by the + laws of Finland. If you are a consumer in the EU or EEA, you also keep the + protection of the mandatory consumer-law provisions of your country of residence. Questions: + email {CONTACT_EMAIL}{' '} + or open an issue on GitHub. +

    +
    +
    +); diff --git a/components/PricingPage.tsx b/components/PricingPage.tsx new file mode 100644 index 0000000..f00fc9a --- /dev/null +++ b/components/PricingPage.tsx @@ -0,0 +1,329 @@ +import React, { useState } from 'react'; +import { + BarChart2, Check, Crown, Github, Heart, ArrowRight, Sparkles, Cloud, + Link2, Layers, BookOpen, Loader2, Coffee, Star, GraduationCap, ShieldCheck, +} from 'lucide-react'; +import { useAuth } from '../services/auth'; +import { startCheckout } from '../services/billing'; +import AuthModal from './AuthModal'; + +interface PricingPageProps { + onOpenEditor: () => void; + onOpenLanding: () => void; + onOpenCompare: () => void; + onOpenSettings: () => void; +} + +const FREE_FEATURES = [ + 'Unlimited diagrams and projects', + 'Every drawing tool and all 15+ built-in templates', + 'All export formats (SVG, PNG, JPEG) at full quality, no watermark, ever', + 'Unlimited AI generation with your own free API key (BYOK)', + 'Local JSON backup & restore of everything', + 'Open source (AGPL-3.0), inspect it, fork it, self-host it', +]; + +const SUPPORTER_FEATURES: { icon: React.ReactNode; text: string }[] = [ + { icon: , text: 'Hosted AI, no API key setup, 150 generations/month included' }, + { icon: , text: 'Cloud sync across devices (school laptop and home) with version history' }, + { icon: , text: 'Shareable view-only links, send a diagram to your teacher or group partner' }, + { icon: , text: 'Custom template library, save your own curve setups, synced' }, + { icon: , text: 'Supporter badge + your name in the README (optional)' }, +]; + +const FAQ: { q: string; a: string }[] = [ + { + q: 'Will features ever move from Free to paid?', + a: 'No. That is the whole point of the guarantee: unlimited diagrams, all tools and templates, full-quality watermark-free exports, unlimited BYOK AI, and local backup stay free forever. Supporter only adds hosted conveniences that genuinely cost money to run (servers, hosted AI).', + }, + { + q: 'What happens to my data if I cancel Supporter?', + a: 'You keep everything. Your data always lives in your browser first, you can export a full JSON backup any time, and reading your synced data is never locked, only new cloud writes pause until you resubscribe.', + }, + { + q: 'Is VAT included? Can I get an invoice?', + a: 'Yes. Payments are processed by Polar as merchant of record, which handles EU VAT and provides invoices from the billing portal.', + }, + { + q: 'Is my work private?', + a: 'Yes. Locally, everything stays in your browser. With sync, data is stored under your account (row-level security). Share links contain only the diagram, never your AI chat history, and can be revoked at any time.', + }, + { + q: "I'm a teacher, can I get this for my whole class?", + a: "The free tier already covers everything a class needs for IAs. If there's genuine demand for a Classroom plan (one license, whole class gets Supporter), it will happen, open a GitHub issue to register interest.", + }, +]; + +const PricingPage: React.FC = ({ onOpenEditor, onOpenLanding, onOpenCompare, onOpenSettings }) => { + const { configured, user, isPro } = useAuth(); + const [interval, setInterval] = useState<'month' | 'year'>('month'); + const [checkoutLoading, setCheckoutLoading] = useState(false); + const [checkoutError, setCheckoutError] = useState(null); + const [authModalOpen, setAuthModalOpen] = useState(false); + + const handleSubscribe = async () => { + setCheckoutError(null); + if (!configured) { + setCheckoutError('Billing is not configured on this deployment.'); + return; + } + if (!user) { + setAuthModalOpen(true); + return; + } + if (isPro) { + onOpenSettings(); + return; + } + setCheckoutLoading(true); + const result = await startCheckout(interval); + setCheckoutLoading(false); + if (result.url) { + window.location.href = result.url; + } else { + setCheckoutError(result.error ?? 'Could not start checkout.'); + } + }; + + return ( +
    + setAuthModalOpen(false)} + title="Sign in to continue" + message="Create a free account first (it takes a few seconds). Once you're signed in, click Become a Supporter again to go to checkout." + /> + + {/* Nav */} + + + {/* Hero */} +
    +
    +
    + + The guarantee +
    +

    + Everything a student needs to finish their IA is{' '} + + free and unlimited, forever. + +

    +

    + No trials, no watermarks, no export paywalls, no diagram limits. + The Supporter plan exists for hosted convenience and for people who want + to keep this project alive. +

    +
    +
    + + {/* Plans */} +
    +
    + {/* Free */} +
    +
    +
    + +

    Free

    +
    +
    + $0 + forever +
    +

    Everything you need for your IA, Paper 1, and beyond.

    +
    +
      + {FREE_FEATURES.map((feature, i) => ( +
    • +
      + +
      + {feature} +
    • + ))} +
    + +
    + + {/* Supporter */} +
    +
    +
    + +

    Supporter

    +
    +
    + {interval === 'month' ? '$5' : '$50'} + /{interval === 'month' ? 'month' : 'year'} +
    +
    + + +
    +
    +
    Everything in Free, plus:
    +
      + {SUPPORTER_FEATURES.map((feature, i) => ( +
    • +
      + {feature.icon} +
      + {feature.text} +
    • + ))} +
    + + {checkoutError && ( +
    {checkoutError}
    + )} +

    + Payments processed by Polar. +

    +
    +
    +
    + + {/* Other ways to support */} +
    +
    +

    + Other ways to support the project +

    +

    + Not into subscriptions? One-off support keeps the lights on just as well. And starring the + repo helps more students find a free tool. +

    + +
    +
    + + {/* FAQ */} +
    +
    +

    + Questions, answered honestly +

    +
    + {FAQ.map((item, i) => ( +
    +

    {item.q}

    +

    {item.a}

    +
    + ))} +
    +
    +
    + + {/* Footer */} +
    +
    + +
    + + + GitHub + + AGPL-3.0 +
    +
    +
    +
    + ); +}; + +export default PricingPage; diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 3067db1..231a2db 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -1,7 +1,8 @@ import React, { useState, useRef, useEffect } from 'react'; import { ChevronLeft, Key, Eye, EyeOff, Check, AlertTriangle, - Download, Upload, BarChart2, Trash2, ExternalLink, Cpu, RefreshCw + Download, Upload, BarChart2, Trash2, ExternalLink, Cpu, RefreshCw, + Crown, Sparkles } from 'lucide-react'; import { getApiKey as getGeminiApiKey, @@ -24,12 +25,18 @@ import { import { AIProvider, getAIProvider, setAIProvider } from '../services/aiProvider'; import { Graph, Project } from '../types'; import { ConfirmModal } from './Modal'; +import AccountSection from './AccountSection'; +import { useAuth } from '../services/auth'; +import { SyncState } from '../services/useCloudSync'; interface SettingsPageProps { onBack: () => void; graphs: Graph[]; projects: Project[]; onImportData: (data: { graphs: Graph[]; projects: Project[]; specialColors?: string[]; standardColors?: string[] }) => void; + syncState: SyncState; + onSyncNow: () => void; + onOpenPricing: () => void; } const EXPORT_VERSION = 1; @@ -51,7 +58,11 @@ const SettingsPage: React.FC = ({ graphs, projects, onImportData, + syncState, + onSyncNow, + onOpenPricing, }) => { + const { configured: cloudConfigured, user, isPro } = useAuth(); const [provider, setProviderState] = useState(() => getAIProvider()); const [apiKey, setApiKey] = useState(''); const [showKey, setShowKey] = useState(false); @@ -73,6 +84,21 @@ const SettingsPage: React.FC = ({ const [openRouterModelsError, setOpenRouterModelsError] = useState(null); const loadProviderState = (p: AIProvider) => { + if (p === 'hosted') { + // Hosted provider has no key or model selection, server-managed. + setApiKey(''); + setKeyConfigured(false); + setSelectedModel(''); + setAvailableModels([]); + setModelsFetched(false); + setModelsError(null); + setLoadingModels(false); + setOpenRouterModels([]); + setOpenRouterModelsFetched(false); + setOpenRouterModelsError(null); + setOpenRouterModelsLoading(false); + return; + } const existingKey = p === 'openrouter' ? getOpenRouterApiKey() : getGeminiApiKey(); if (existingKey) { setApiKey(existingKey); @@ -309,7 +335,7 @@ const SettingsPage: React.FC = ({ }} onConfirm={confirmImport} title="Import Backup Data" - message={`This will replace all your current data with ${pendingImportData?.graphs.length || 0} graph${(pendingImportData?.graphs.length || 0) !== 1 ? 's' : ''} and ${pendingImportData?.projects.length || 0} project${(pendingImportData?.projects.length || 0) !== 1 ? 's' : ''}. This action cannot be undone.`} + message={`This will replace all your current diagrams and projects, including any synced from your other devices, with ${pendingImportData?.graphs.length || 0} diagram${(pendingImportData?.graphs.length || 0) !== 1 ? 's' : ''} and ${pendingImportData?.projects.length || 0} project${(pendingImportData?.projects.length || 0) !== 1 ? 's' : ''} from this backup. This can't be undone.`} confirmText="Import" variant="danger" /> @@ -338,6 +364,13 @@ const SettingsPage: React.FC = ({
    + {/* Account & Cloud Section */} + + {/* API Key Section */}
    @@ -363,10 +396,49 @@ const SettingsPage: React.FC = ({ onChange={(e) => handleProviderChange(e.target.value as AIProvider)} className="w-full px-4 py-3 rounded-lg border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none text-sm bg-gray-50 transition-all" > - - + + + {cloudConfigured && ( + + )}
    + + {/* Hosted provider status */} + {provider === 'hosted' && ( +
    + {user && isPro ? ( +
    + + Hosted AI is active, no API key needed. Usage is shown in Account & Cloud above. +
    + ) : user ? ( +
    +
    + + Hosted AI is part of the Supporter plan ($5/month). +
    + +
    + ) : ( +
    + + Sign in above to use hosted AI, or pick a free BYOK provider. +
    + )} +

    + Prefer full control? Both BYOK providers stay free and unlimited with your own key. +

    +
    + )} + + {provider !== 'hosted' && (<> {/* Status indicator */}
    = ({ ) : ( <> - No API key configured — AI features are disabled + No API key configured, AI features are disabled )}
    @@ -467,10 +539,12 @@ const SettingsPage: React.FC = ({
    + )}
    {/* Model Selection Section */} + {provider !== 'hosted' && (
    @@ -663,6 +737,7 @@ const SettingsPage: React.FC = ({ )}
    + )} {/* Import/Export Section */}
    @@ -742,6 +817,33 @@ const SettingsPage: React.FC = ({ )}
    + + {/* About / source. AGPL-3.0 requires offering the source to + everyone who interacts with the app over a network. */} + ); diff --git a/components/ShareModal.tsx b/components/ShareModal.tsx new file mode 100644 index 0000000..9522482 --- /dev/null +++ b/components/ShareModal.tsx @@ -0,0 +1,182 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Link2, Copy, Check, Loader2, Trash2, Crown, LogIn } from 'lucide-react'; +import { Modal } from './Modal'; +import { useAuth } from '../services/auth'; +import { + createOrUpdateGraphShare, + getShareIdForGraph, + revokeShare, + shareUrl, +} from '../services/shares'; +import { Graph } from '../types'; + +interface ShareModalProps { + isOpen: boolean; + onClose: () => void; + graph: Graph | null; + onOpenSettings: () => void; + onOpenPricing: () => void; +} + +/** + * Creates/copies/revokes a view-only link for the active graph. + * Supporter feature, non-entitled users see the upgrade path instead. + */ +export const ShareModal: React.FC = ({ isOpen, onClose, graph, onOpenSettings, onOpenPricing }) => { + const { configured, user, isPro } = useAuth(); + const [shareId, setShareId] = useState(null); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen || !graph || !user || !isPro) return; + let cancelled = false; + setLoading(true); + setError(null); + setCopied(false); + getShareIdForGraph(graph.id) + .then((id) => { if (!cancelled) setShareId(id); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [isOpen, graph, user, isPro]); + + const handleCreate = useCallback(async () => { + if (!graph || !user || creating) return; + setCreating(true); + setError(null); + const result = await createOrUpdateGraphShare(user.id, graph); + setCreating(false); + if (result.error) { + setError(result.error); + } else if (result.id) { + setShareId(result.id); + } + }, [graph, user, creating]); + + const handleCopy = useCallback(async () => { + if (!shareId) return; + try { + await navigator.clipboard.writeText(shareUrl(shareId)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setError('Could not copy, select the link and copy it manually.'); + } + }, [shareId]); + + const handleRevoke = useCallback(async () => { + if (!shareId) return; + const result = await revokeShare(shareId); + if (result.error) { + setError(result.error); + } else { + setShareId(null); + } + }, [shareId]); + + if (!isOpen) return null; + + return ( + + {!configured ? ( +

    + Sharing isn't available on this deployment. You can still export the + diagram as SVG/PNG and send the file. +

    + ) : !user ? ( +
    +

    + Sign in to create a view-only link you can send to your teacher or group partner. +

    + +
    + ) : !isPro ? ( +
    +
    + +
    +

    + Shareable links are part of the Supporter plan + ($5/month). Unable to support? Everything you need to finish your IA, editor, exports, AI with + your own key, still stays free forever. +

    + +
    + ) : loading ? ( +
    + +
    + ) : shareId ? ( +
    +

    + Anyone with this link can view the + diagram (never your chat history). It stays up to date as you edit and sync. +

    +
    +
    + + {shareUrl(shareId)} +
    + +
    +
    + + +
    + {error &&
    {error}
    } +
    + ) : ( +
    +

    + Create a view-only link for + {' '}{graph?.diagramData.title || 'this graph'}. + Perfect for sending to a teacher or group partner without exporting files. +

    + + {error &&
    {error}
    } +
    + )} +
    + ); +}; + +export default ShareModal; diff --git a/components/SharedViewPage.tsx b/components/SharedViewPage.tsx new file mode 100644 index 0000000..3b39312 --- /dev/null +++ b/components/SharedViewPage.tsx @@ -0,0 +1,179 @@ +import React, { useState, useEffect } from 'react'; +import { BarChart2, Loader2, AlertTriangle, ArrowRight } from 'lucide-react'; +import DiagramRenderer from './DiagramRenderer'; +import { fetchSharedPayload, SharePayload, SharedGraphEntry } from '../services/shares'; +import { DEFAULT_EDITOR_SETTINGS } from '../types'; + +interface SharedViewPageProps { + slug: string; + onGoHome: () => void; +} + +/** + * Public, read-only viewer for shared diagram/project links (/s/:slug). + * No account needed, anyone with the link can view. + */ +const SharedViewPage: React.FC = ({ slug, onGoHome }) => { + const [payload, setPayload] = useState(null); + const [status, setStatus] = useState<'loading' | 'ready' | 'notfound' | 'error'>('loading'); + const [activeIndex, setActiveIndex] = useState(0); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + let cancelled = false; + setStatus('loading'); + fetchSharedPayload(slug) + .then((data) => { + if (cancelled) return; + if (data) { + setPayload(data); + setStatus('ready'); + } else { + setStatus('notfound'); + } + }) + .catch(() => { + if (!cancelled) setStatus('error'); + }); + return () => { cancelled = true; }; + }, [slug, reloadKey]); + + const graphs: SharedGraphEntry[] = payload + ? payload.kind === 'graph' + ? [{ id: 'single', title: payload.title, caption: payload.caption, diagramData: payload.diagramData }] + : payload.graphs + : []; + const active = graphs[Math.min(activeIndex, Math.max(graphs.length - 1, 0))]; + + return ( +
    + {/* Header */} +
    +
    +
    +
    + +
    +
    +
    + {payload?.kind === 'project' ? payload.name : active?.title || 'Shared diagram'} +
    +
    Shared view-only · IB EconGraph AI
    +
    +
    + +
    +
    + +
    + {status === 'loading' && ( +
    + +
    + )} + + {status === 'notfound' && ( +
    +
    +
    + +
    +

    This link isn't available

    +

    + The share link may have been revoked, or the diagram was deleted by its owner. +

    + +
    +
    + )} + + {status === 'error' && ( +
    +
    +
    + +
    +

    Couldn't load this link

    +

    + Something went wrong reaching the server. Check your connection and try again. +

    + +
    +
    + )} + + {status === 'ready' && payload && ( + <> + {payload.kind === 'project' && graphs.length > 1 && ( + + )} + +
    + {active ? ( +
    + + {active.caption && ( +

    {active.caption}

    + )} +
    + ) : ( +

    This project has no graphs yet.

    + )} +
    + + )} +
    + +
    + Made with{' '} + {' '} + , the free, open-source economics diagram editor for IB students. +
    +
    + ); +}; + +export default SharedViewPage; diff --git a/docs/BACKEND_SETUP.md b/docs/BACKEND_SETUP.md new file mode 100644 index 0000000..b9b3656 --- /dev/null +++ b/docs/BACKEND_SETUP.md @@ -0,0 +1,261 @@ +# Backend Setup — Accounts, Cloud Sync & the Supporter Plan + +IB EconGraph AI runs **fully free and local by default**: no accounts, no server, +data in `localStorage`, AI via the user's own API key. This guide configures the +optional cloud backend that powers the **Supporter** plan: + +| Feature | Needs | +|---|---| +| Sign-in (email + password / Google) | Supabase | +| Cloud sync + version history | Supabase | +| Shareable view-only links | Supabase | +| Custom template library | Supabase | +| Hosted AI (no BYOK key) | Supabase + a server AI key (Vertex AI or Google AI Studio) | +| Subscriptions / billing | Polar | + +If any environment variable is missing, the related feature quietly disappears +from the UI — a fork with zero configuration still works perfectly. + +--- + +## 1. Supabase (auth + database) + +1. Create a project at [supabase.com](https://supabase.com) (free tier is fine). +2. In the **SQL Editor**, paste and run the entire contents of + [`supabase/schema.sql`](../supabase/schema.sql). It is idempotent — safe to + re-run after updates. +3. **Auth → Providers → Email**: keep it enabled and turn **"Confirm email" ON**. + The app uses **email + password** with one-time email verification (not magic + links, which would send an email on every login). Optionally enable **Google** + and add your OAuth client ID/secret — Google sign-in sends **no** emails, so + it's the cheapest option for users. Also turn on **leaked-password protection** + (Auth → Providers/Policies → "Prevent use of compromised passwords" / + HaveIBeenPwned) — this clears the `auth_leaked_password_protection` linter warning. +4. **Auth → URL Configuration**: + - **Site URL** → your deployment (e.g. `https://ib-econgraph-ai.vercel.app`). + - **Redirect URLs** → add every origin you sign in from, so confirmation and + password-reset links return to `/settings`. Include your prod domain plus, + for local testing, `http://localhost:4000/**` and your dev-tunnel + `https://.devtunnels.ms/**`. If an origin isn't listed, Supabase falls + back to the Site URL and the link won't land on Settings. +5. **Auth > Emails / SMTP.** Supabase's built-in mailer is capped at **2 emails + per hour** and is explicitly **not for production**. Verification and + password-reset emails go to real users, so you need a sender their inboxes will + accept. Options for a free setup with **no custom domain**: + - **Gmail SMTP (recommended free, no-domain option).** Send through your own + Gmail account. Turn on 2-Step Verification for the Google account, generate an + **App Password** (Google Account, Security, App passwords), then in Supabase + set custom SMTP to host `smtp.gmail.com`, port `465` (SSL) or `587` (TLS), + username = your Gmail address, password = the App Password, sender = the same + Gmail address. Because the mail actually leaves Google's servers, SPF/DKIM + line up and it reaches inboxes rather than spam. Gmail allows roughly **500 + recipients/day**, far more than auth emails need. Good for a small app; move + to a domain-based sender if you ever outgrow it. + - **Lean on Google sign-in** (zero emails) as the primary path, with + email+password as the fallback. This keeps email volume tiny whatever SMTP + you use. + - **A note on Resend / Brevo / Mailjet.** These are good services, but to send + to *other people* they need a **verified domain** (you add DNS records). Their + free shared senders (for example `onboarding@resend.dev`) can only email your + own account, so without a domain they're testing-only. Once you have a cheap + domain, Resend's free tier (100/day, 3k/month) is the clean upgrade from Gmail + SMTP, and it raises Supabase's initial send limit to 30/hour (adjustable). + + Custom SMTP is a Supabase setting, not a Vercel/hosting one, so it doesn't + conflict with staying on Vercel's free plan. +6. Collect the keys from **Project Settings → API Keys**: + - Project URL → `VITE_SUPABASE_URL` *and* `SUPABASE_URL` + - **Publishable key** (`sb_publishable_…`) → `VITE_SUPABASE_PUBLISHABLE_KEY`. + This is the modern replacement for the legacy `anon` key — low-privilege and + safe to ship in the client bundle. + - **Secret key** (`sb_secret_…`) → `SUPABASE_SECRET_KEY` (server-side only, + never expose). This replaces the legacy `service_role` key; it bypasses RLS + and Supabase rejects it outright if it's ever sent from a browser. + +### Security model (already encoded in schema.sql) + +- All tables have row-level security. Users can only read their own rows. + The `shares` table is **not** publicly readable — anonymous SELECT is revoked + and view-only links resolve through the `get_share(id)` security-definer RPC, + which returns just the diagram payload (never the owner id or other shares), + so the 96-bit slugs can't be bulk-enumerated. +- **Writes** to synced data require an active Supporter entitlement + (`is_pro()`); **reads are never gated**, so lapsed subscribers can always + retrieve their data. +- Billing columns on `profiles` are writable only via the secret key + (column-level grants); users can edit only display/supporter-name fields. +- AI usage metering uses atomic SQL functions callable only with the secret key. + +## 2. Hosted AI + +The server generates diagrams for supporters using **one** of three backends. +They are tried in this order and the first one configured wins, so set the +variables for exactly one. All are set on the server (Vercel > Project > +Settings > Environment Variables). + +**Option A — Vertex AI express mode.** Vertex AI was renamed *Gemini Enterprise +Agent Platform* in 2026, but the API is the same. Express mode gives you a single +API key with no service account, so it just works on serverless. Create the key +in the Google Cloud console (express mode), then set: + +``` +VERTEX_API_KEY=... # Vertex express-mode API key +HOSTED_AI_MONTHLY_LIMIT=150 +HOSTED_AI_MODEL=gemini-2.5-flash +``` + +> Personal-account caveat: creating a Vertex API key requires a Google Cloud +> **organization**. The Google-managed constraint +> `iam.managed.disableServiceAccountApiKeyCreation` is enforced by default and +> can only be lifted at the org level, so a plain personal (@gmail.com) account +> with no organization cannot create one. If that's you, use Option B (a +> service-account key, which is *not* blocked on a no-org project) or Option C. + +**Option B — Vertex AI with a project (works on a personal, no-org account).** +Use a GCP project id (plus an optional location, default `global`). Locally the +server authenticates with your gcloud Application Default Credentials, so run +`gcloud auth application-default login` once. Vercel has no gcloud, so there you +must also create a service account with the *Vertex AI User* role and paste its +key JSON, as a single line, into `GOOGLE_SERVICE_ACCOUNT_JSON`: + +``` +GOOGLE_CLOUD_PROJECT=your-project-id +GOOGLE_CLOUD_LOCATION=global +GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account", ...} # Vercel only +HOSTED_AI_MONTHLY_LIMIT=150 +HOSTED_AI_MODEL=gemini-2.5-flash +``` + +**Option C — Gemini Developer API (Google AI Studio).** The simplest fully-free +option. Get a key at : + +``` +GEMINI_API_KEY=... # Google AI Studio key +HOSTED_AI_MONTHLY_LIMIT=150 +HOSTED_AI_MODEL=gemini-2.5-flash +``` + +Cost check: Gemini Flash costs well under $0.01 per diagram generation, so 150 +generations cost far less than the $5/month plan price. Vertex (A/B) bills +through Google Cloud; AI Studio (C) has a free tier. + +## 3. Polar (billing) + +1. Create an organization at [polar.sh](https://polar.sh) + (use [sandbox.polar.sh](https://sandbox.polar.sh) for testing with + `POLAR_SERVER=sandbox`). +2. Create **two products**, both "Software subscription": + - *EconGraph Supporter (Monthly)* — $5 / month + - *EconGraph Supporter (Yearly)* — $50 / year + Copy each product ID into `POLAR_PRODUCT_ID_MONTHLY` / `POLAR_PRODUCT_ID_YEARLY`. +3. Create an **access token** (Settings → Developers) with `checkouts:write`, + `customer_sessions:write`, `customers:read`, and `subscriptions:write` scopes + → `POLAR_ACCESS_TOKEN`. (`subscriptions:write` lets the account-deletion + endpoint cancel a user's subscription so a deleted account isn't billed.) +4. Add a **webhook** (Settings → Webhooks): + - URL: `https:///api/webhooks/polar` + - Format: RAW + - Events: all `subscription.*` events (created, active, updated, canceled, + uncanceled, revoked, past_due) + - Copy the signing secret → `POLAR_WEBHOOK_SECRET` +5. Polar acts as **merchant of record**, so EU VAT is handled for you. + +The webhook keeps `profiles.pro_status` / `pro_until` in sync. Entitlement = +`pro_until > now()`; the server grants a 3-day grace period past each billing +period end so renewals never cause flapping. + +## 4. Vercel environment variables — summary + +| Variable | Scope | Purpose | +|---|---|---| +| `VITE_SUPABASE_URL` | build (client) | Supabase project URL | +| `VITE_SUPABASE_PUBLISHABLE_KEY` | build (client) | Supabase publishable key (`sb_publishable_…`) | +| `SUPABASE_URL` | server | same URL, for API routes | +| `SUPABASE_SECRET_KEY` | server | Supabase secret key (`sb_secret_…`) — never expose | +| `VERTEX_API_KEY` | server | hosted AI via Vertex express mode (option A) | +| `GOOGLE_CLOUD_PROJECT` | server | hosted AI via Vertex project (option B) | +| `GOOGLE_CLOUD_LOCATION` | server | Vertex location, default `global` | +| `GOOGLE_SERVICE_ACCOUNT_JSON` | server | Vertex service-account key JSON (option B on Vercel) | +| `GEMINI_API_KEY` | server | hosted AI via Google AI Studio (option C) | +| `HOSTED_AI_MONTHLY_LIMIT` | server | default 150 | +| `HOSTED_AI_MODEL` | server | default `gemini-2.5-flash` | +| `POLAR_ACCESS_TOKEN` | server | Polar API | +| `POLAR_WEBHOOK_SECRET` | server | webhook signature verification | +| `POLAR_PRODUCT_ID_MONTHLY` | server | monthly product | +| `POLAR_PRODUCT_ID_YEARLY` | server | yearly product | +| `POLAR_SERVER` | server | `production` or `sandbox` | +| `APP_URL` | server | canonical site URL for checkout redirects | + +## 5. Testing the full flow + +> **Local dev serves the API for you.** `npm run dev` (Vite) mounts the `api/*` +> functions in-process via a dev-only plugin (see `vite.config.ts`), so +> `/api/checkout`, `/api/usage`, etc. work on `http://localhost:4000` with no +> Vercel CLI needed — it reads your local `.env` for the server-side vars. For +> local checkout redirects, set `APP_URL=http://localhost:4000`. +> (`npm run dev:api` = `vercel dev` is an alternative that runs the real Vercel +> runtime, but it needs `vercel login`/`link` and is finicky on Windows + Node 24.) +> +> **Webhook reachability:** the entitlement flip to Supporter is driven by the +> Polar `subscription.*` webhook, and Polar (even in sandbox) can only reach a +> **public** URL — not `localhost`. So the checkout will open and complete +> locally, but the profile won't turn Pro until the webhook hits a reachable +> `/api/webhooks/polar`. For a true end-to-end test, either deploy a Vercel +> preview and point the Polar sandbox webhook at it, or expose your local +> server with a tunnel (ngrok/cloudflared) and use that URL in Polar. + +1. Deploy (or run `npm run dev:api`) with sandbox Polar + a real Supabase project. +2. Create an account with email + password (or Google) in Settings → Account & + Cloud. With "Confirm email" on you'll get a verification link that returns to + `/settings`; locally, either use Google or confirm the user in Supabase → + Auth → Users. +3. Pricing page → Become a Supporter → complete the sandbox checkout + (test card `4242 4242 4242 4242`). +4. You are redirected to `/settings?checkout=success`; within a few seconds the + webhook flips the profile to Supporter and the UI updates. +5. Verify: cloud sync status turns active, hosted AI provider works, a share + link opens in an incognito window, and canceling in the billing portal + downgrades after the period ends. + +## 6. Updating the README supporters list + +Fetches Supporters who opted in (Settings → "Show me in the README") and +rewrites the block between the `SUPPORTERS:START/END` markers in `README.md`. + +**Automated (recommended):** the workflow `.github/workflows/update-supporters.yml` +runs it **every Monday** (and on-demand from the Actions tab) and commits any +change. Add two repository secrets under **Settings → Secrets and variables → +Actions**: `SUPABASE_URL` and `SUPABASE_SECRET_KEY`. Nothing else to run. + +**Manually**, if you prefer: + +```bash +SUPABASE_URL=... SUPABASE_SECRET_KEY=... node scripts/update-supporters.mjs +``` + +Note it lists only *current* Supporters (subscription still active) and always +reflects each person's latest chosen name, so name changes are picked up on the +next run. + +## 7. Account deletion (GDPR) + +Users can permanently delete their account and all cloud data from **Settings → +Account & Cloud → Delete account** (backed by `/api/delete-account`). It cancels +any active Polar subscription first (needs the `subscriptions:write` token scope), +then deletes the auth user — which cascades to every table via `on delete +cascade`. Local, unsynced diagrams on the user's device are untouched. + +## 8. Free-tier fit (Supabase) + +Everything here fits Supabase's free plan for a small project: 500 MB database, +1 GB storage, 5 GB egress/month, 50,000 monthly active users, unlimited API +requests. The main watch-outs: the **2 emails/hour** auth mailer (see §1.5), and +free projects **pause after 7 days of inactivity**. Vercel's Hobby plan hosts +the app + API functions for free. + +To keep a low-traffic project from pausing, this repo ships a GitHub Actions +workflow, `.github/workflows/db-keepalive.yml`, that runs every ~5 days and does +one cheap read against the database. It reuses the same `SUPABASE_URL` and +`SUPABASE_SECRET_KEY` repository secrets as the supporters workflow (Settings > +Secrets and variables > Actions), and you can also trigger it manually from the +Actions tab. If those secrets are absent it exits cleanly without failing. diff --git a/index.html b/index.html index da592c0..beb990f 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - IB EconGraph AI — Free AI-Powered Economics Diagram Editor + IB EconGraph AI: Free AI-Powered Economics Diagram Editor - + @@ -30,7 +30,7 @@ - + diff --git a/index.tsx b/index.tsx index 6ca5361..4efdba3 100644 --- a/index.tsx +++ b/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { AuthProvider } from './services/auth'; const rootElement = document.getElementById('root'); if (!rootElement) { @@ -10,6 +11,8 @@ if (!rootElement) { const root = ReactDOM.createRoot(rootElement); root.render( - + + + -); \ No newline at end of file +); diff --git a/package-lock.json b/package-lock.json index ab12670..8fa544a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "ib-econgraph-ai", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ib-econgraph-ai", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "@google/genai": "^1.31.0", + "@polar-sh/sdk": "^0.48.1", + "@supabase/supabase-js": "^2.110.7", "lucide-react": "^0.555.0", "react": "^19.2.1", "react-dom": "^19.2.1", @@ -17,6 +19,9 @@ }, "devDependencies": { "@types/node": "^22.14.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vercel/node": "^5.8.26", "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.8.2", "vite": "^6.2.0" @@ -304,6 +309,66 @@ "node": ">=6.9.0" } }, + "node_modules/@bytecodealliance/preview2-shim": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz", + "integrity": "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)" + }, + "node_modules/@edge-runtime/format": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@edge-runtime/format/-/format-2.2.1.tgz", + "integrity": "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/node-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/node-utils/-/node-utils-2.3.0.tgz", + "integrity": "sha512-uUtx8BFoO1hNxtHjp3eqVPC/mWImGb2exOfGjMLUoipuWgjej+f4o/VP4bUI8U40gu7Teogd5VTeZUkGvJSPOQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/ponyfill": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@edge-runtime/ponyfill/-/ponyfill-2.4.2.tgz", + "integrity": "sha512-oN17GjFr69chu6sDLvXxdhg0Qe8EZviGSuqzR9qOiKh4MhFYGdBBcqRNzdmYeAdeRzOW2mM9yil4RftUQ7sUOA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/primitives": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-4.1.0.tgz", + "integrity": "sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-3.2.0.tgz", + "integrity": "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@edge-runtime/primitives": "4.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -746,6 +811,16 @@ "node": ">=18" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@google/genai": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.40.0.tgz", @@ -768,6 +843,29 @@ } } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -785,6 +883,19 @@ "node": ">=12" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -835,6 +946,100 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", + "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.4.0" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -845,6 +1050,15 @@ "node": ">=14" } }, + "node_modules/@polar-sh/sdk": { + "version": "0.48.1", + "resolved": "https://registry.npmjs.org/@polar-sh/sdk/-/sdk-0.48.1.tgz", + "integrity": "sha512-FmU6eLJRXJ6Zau0IkqscfkFta8xxYbFV1VD9zzS2Ki2btCuFmUyPZOkHOXU77R892h2zyBegLnHS7jrOhMN1Sw==", + "dependencies": { + "standardwebhooks": "^1.0.0", + "zod": "^3.25.65 || ^4.0.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -909,6 +1123,17 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@renovatebot/pep440": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz", + "integrity": "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.9.0 || ^22.11.0 || ^24", + "pnpm": "^10.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -916,6 +1141,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1266,6 +1514,133 @@ "win32" ] }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@supabase/auth-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.7.tgz", + "integrity": "sha512-M5Bpl4hCv6kHcOO/xM06Dyfg1mYLHljMkp1plhzG9IRZPc3czvyMsSN1XpL5+GKisOKM3lSN59zhpcm6sMVXfA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.7.tgz", + "integrity": "sha512-megYmexlYEoR/0qlsr4Snh9wtzAodO7MAri3NMevZrXzNvQRKlvmTcSBoKGLQEPDakgDZMqbMdf9DwoZz6qfoA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.7.tgz", + "integrity": "sha512-ban6YV0djhVaqVYezlOARKLIuOBSvLLhyQVZjA2nxPrtswhxHCl1+gI4giFgI9ATQAaMNbUZb4JXiuL5lEA/5g==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.7.tgz", + "integrity": "sha512-AMtZjyFA2gsmjuxopPNS/sRznLQHG0Ht5x+ytTPTOh3vAcOTUlVRLx7gW4/CONNnbb3PKOkE+HmM35HOSbmomQ==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.7.tgz", + "integrity": "sha512-2tcDE8cjEDy1uKxKavBpKQod1JdMV1jDXQag48TCa+kycmJOltc0yVabC0BUlhOwAl6WykXU2aOsH3ELMtZrmQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.7.tgz", + "integrity": "sha512-AnfO3A230Shy6RMO7cya3Wl1OcXnABJrzH8vP+fY7/RFjhzcchB7DjKkkTIAntlwekD+GkSFzEvt2tC+D4Fp8w==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.7", + "@supabase/functions-js": "2.110.7", + "@supabase/postgrest-js": "2.110.7", + "@supabase/realtime-js": "2.110.7", + "@supabase/storage-js": "2.110.7" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.11.1.tgz", + "integrity": "sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.7", + "minimatch": "^3.0.4", + "mkdirp": "^1.0.4", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1318,6 +1693,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", @@ -1327,1126 +1709,3223 @@ "undici-types": "~6.21.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", - "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.2", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@types/react": "^19.2.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/@vercel/build-utils": { + "version": "13.34.0", + "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.34.0.tgz", + "integrity": "sha512-DhbYXymwjO6N3prqBf7UzQFNpQIcHabe1qbiXxqqfjDJ5Tca9+MMYnU/+uObHrPBfR9FAXMxAwmkIUw59zJ7cw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/python-analysis": "0.11.1", + "cjs-module-lexer": "1.2.3", + "es-module-lexer": "1.5.0" + } + }, + "node_modules/@vercel/build-utils/node_modules/es-module-lexer": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", + "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/error-utils": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.0.tgz", + "integrity": "sha512-WFWiRxfPzoYWYifaj4thSKvAaZZwUOqD4k5GINRIgZgCiS2E3iAJbWbIsIZmkQdTecWFHcWGA6q48CjisgpOBA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@vercel/nft": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.10.0.tgz", + "integrity": "sha512-iLOW4fcsgkipfOh2Bw3wB38YDfxTlxr7+j4uFeui2OswkNT28jIitS/aMce7tS0mef1YPQ8zLIDYr3a0aahNrA==", + "dev": true, "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^2.0.0", + "@rollup/pluginutils": "^5.1.3", + "acorn": "^8.6.0", + "acorn-import-attributes": "^1.9.5", + "async-sema": "^3.1.1", + "bindings": "^1.4.0", + "estree-walker": "2.0.2", + "glob": "^13.0.0", + "graceful-fs": "^4.2.9", + "node-gyp-build": "^4.2.2", + "picomatch": "^4.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "nft": "out/cli.js" + }, "engines": { - "node": ">= 14" + "node": ">=20" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@vercel/nft/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": "18 || 20 || >=22" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@vercel/nft/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "balanced-match": "^4.0.2" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" + "node_modules/@vercel/nft/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "node_modules/@vercel/nft/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", + "node_modules/@vercel/nft/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/brace-expansion": { + "node_modules/@vercel/nft/node_modules/path-scurry": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "balanced-match": "^1.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "node_modules/@vercel/node": { + "version": "5.8.26", + "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.26.tgz", + "integrity": "sha512-Vh7V6aWgUtzMLqd+n7K4O4KzXjB9UxV71OGCp5NODPqHvjpbavwjUfPlnKAXyZTZxQrbp+HHv+mL/6J8ezB8Hw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "license": "Apache-2.0", + "dependencies": { + "@edge-runtime/node-utils": "2.3.0", + "@edge-runtime/primitives": "4.1.0", + "@edge-runtime/vm": "3.2.0", + "@types/node": "20.11.0", + "@vercel/build-utils": "13.34.0", + "@vercel/error-utils": "2.2.0", + "@vercel/nft": "1.10.0", + "@vercel/static-config": "3.4.0", + "async-listen": "3.0.0", + "cjs-module-lexer": "1.2.3", + "edge-runtime": "2.5.9", + "es-module-lexer": "1.4.1", + "esbuild": "0.27.0", + "etag": "1.8.1", + "mime-types": "2.1.35", + "node-fetch": "2.6.9", + "path-to-regexp": "6.1.0", + "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", + "ts-morph": "12.0.0", + "tsx": "4.21.0", + "typescript": "npm:typescript@5.9.3", + "undici": "5.28.4" + } + }, + "node_modules/@vercel/node/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" ], + "dev": true, "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=18" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001768", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", - "integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==", + "node_modules/@vercel/node/node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "license": "MIT", + "optional": true, + "os": [ + "android" ], - "license": "CC-BY-4.0" + "engines": { + "node": ">=18" + } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@vercel/node/node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=7.0.0" + "node": ">=18" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/@vercel/node/node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "node_modules/@vercel/node/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 12" + "node": ">=18" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@vercel/node/node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" + "node_modules/@vercel/node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "node_modules/@vercel/node/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "node_modules/@vercel/node/node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/@vercel/node/node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" + "node_modules/@vercel/node/node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/@vercel/node/node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": ">=18" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } + "node_modules/@vercel/node/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" ], + "dev": true, "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.20 || >= 14.13" + "node": ">=18" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "node_modules/@vercel/node/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.20.0" + "node": ">=18" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@vercel/node/node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=18" } }, - "node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, + "node_modules/@vercel/node/node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, + "node_modules/@vercel/node/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { "node": ">=18" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/@vercel/node/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/google-auth-library": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", - "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.0.0", - "gcp-metadata": "^8.0.0", - "google-logging-utils": "^1.0.0", - "gtoken": "^8.0.0", - "jws": "^4.0.0" - }, + "node_modules/@vercel/node/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { "node": ">=18" } }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", + "node_modules/@vercel/node/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=14" + "node": ">=18" } }, - "node_modules/gtoken": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", - "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "node_modules/@vercel/node/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "gaxios": "^7.0.0", - "jws": "^4.0.0" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { "node": ">=18" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/@vercel/node/node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 14" + "node": ">=18" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/@vercel/node/node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "node": ">=18" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/@vercel/node/node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/@vercel/node/node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "node_modules/@vercel/node/node_modules/@types/node": { + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", + "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", + "dev": true, "license": "MIT", "dependencies": { - "bignumber.js": "^9.0.0" + "undici-types": "~5.26.4" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/@vercel/node/node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", "dev": true, + "hasInstallScript": true, "license": "MIT", "bin": { - "json5": "lib/cli.js" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=6" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/@vercel/node/node_modules/node-fetch": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "dev": true, "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" + "node_modules/@vercel/node/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" } }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" + "node_modules/@vercel/node/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/@vercel/python-analysis": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.11.1.tgz", + "integrity": "sha512-EPPLuXJQhIDUx08H9nG76AR2HSgBquwe3OAX5s2w20M923iaWeGGVkhX/4yZ89CJfXEZgE1Aj/mX7lVHOVIcYA==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.555.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", - "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "@bytecodealliance/preview2-shim": "0.17.6", + "@renovatebot/pep440": "4.2.1", + "fs-extra": "11.1.1", + "js-yaml": "4.1.1", + "minimatch": "10.1.1", + "smol-toml": "1.5.2", + "zod": "3.22.4" + } + }, + "node_modules/@vercel/python-analysis/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" + "node_modules/@vercel/python-analysis/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/@vercel/static-config": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.0.tgz", + "integrity": "sha512-wCq90CMUB//ggnFh77NQO1xaLFsS4LigQIqKrH6ohnr9Br/KI1FhlErx62WfCOuueWaW+LVsbLOqNXIUjK8t6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "8.6.3", + "json-schema-to-ts": "1.6.4", + "ts-morph": "12.0.0" + } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/@vitejs/plugin-react": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", + "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.2", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=10.5.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "node": ">=0.4.0" } }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, - "license": "MIT" - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 14" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", + "node_modules/ajv": { + "version": "8.6.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz", + "integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==", + "dev": true, + "license": "MIT", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "ISC" + "license": "Python-2.0" }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/async-listen": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.0.tgz", + "integrity": "sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">= 14" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "type": "github", + "url": "https://github.com/sponsors/feross" }, { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "type": "patreon", + "url": "https://www.patreon.com/feross" }, { - "type": "github", - "url": "https://github.com/sponsors/ai" + "type": "consulting", + "url": "https://feross.org/support" } ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": "*" } }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "file-uri-to-path": "1.0.0" } }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" + "balanced-match": "^1.0.0" } }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { - "rollup": "dist/bin/rollup" + "browserslist": "cli.js" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001768", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", + "integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==", + "dev": true, "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" + "type": "opencollective", + "url": "https://opencollective.com/browserslist" }, { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], + "license": "CC-BY-4.0" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, "license": "MIT" }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "node_modules/code-block-writer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-10.1.1.tgz", + "integrity": "sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==", + "dev": true, "license": "MIT" }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-hrtime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-3.0.0.tgz", + "integrity": "sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/edge-runtime": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/edge-runtime/-/edge-runtime-2.5.9.tgz", + "integrity": "sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@edge-runtime/format": "2.2.1", + "@edge-runtime/ponyfill": "2.4.2", + "@edge-runtime/vm": "3.2.0", + "async-listen": "3.0.1", + "mri": "1.2.0", + "picocolors": "1.0.0", + "pretty-ms": "7.0.1", + "signal-exit": "4.0.2", + "time-span": "4.0.0" + }, "bin": { - "semver": "bin/semver.js" + "edge-runtime": "dist/cli/index.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/edge-runtime/node_modules/async-listen": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.1.tgz", + "integrity": "sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/edge-runtime/node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/edge-runtime/node_modules/signal-exit": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", + "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-1.6.4.tgz", + "integrity": "sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.6", + "ts-toolbelt": "^6.15.5" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.555.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", + "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz", + "integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp-updated": { + "name": "path-to-regexp", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smol-toml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/time-span": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-4.0.0.tgz", + "integrity": "sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-hrtime": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-morph": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-12.0.0.tgz", + "integrity": "sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.11.0", + "code-block-writer": "^10.1.1" + } + }, + "node_modules/ts-toolbelt": { + "version": "6.15.5", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz", + "integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12.0.0" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/typescript": { @@ -2463,12 +4942,35 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2500,6 +5002,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", @@ -2597,6 +5109,24 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2730,6 +5260,15 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index ce0cc80..6bc37f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ib-econgraph-ai", - "version": "1.0.0", + "version": "1.1.0", "description": "Free and open-source AI-powered economics diagram editor built for IB students and educators.", "type": "module", "license": "MIT", @@ -36,18 +36,24 @@ ], "scripts": { "dev": "vite", - "build": "vite build", + "dev:api": "vercel dev --listen 4000", + "build": "vite build && node scripts/generate-seo-pages.mjs", "preview": "vite preview" }, "dependencies": { "@google/genai": "^1.31.0", - "react": "^19.2.1", + "@polar-sh/sdk": "^0.48.1", + "@supabase/supabase-js": "^2.110.7", "lucide-react": "^0.555.0", + "react": "^19.2.1", "react-dom": "^19.2.1", "uuid": "^13.0.0" }, "devDependencies": { "@types/node": "^22.14.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vercel/node": "^5.8.26", "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.8.2", "vite": "^6.2.0" diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index e5ed5a1..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - https://ib-econgraph-ai.vercel.app/ - 2026-02-09 - weekly - 1.0 - - - https://ib-econgraph-ai.vercel.app/home - 2026-02-09 - weekly - 0.8 - - - https://ib-econgraph-ai.vercel.app/editor - 2026-02-09 - weekly - 0.8 - - - https://ib-econgraph-ai.vercel.app/settings - 2026-02-09 - monthly - 0.5 - - diff --git a/scripts/generate-seo-pages.mjs b/scripts/generate-seo-pages.mjs new file mode 100644 index 0000000..8ea7418 --- /dev/null +++ b/scripts/generate-seo-pages.mjs @@ -0,0 +1,418 @@ +// Build-time generator for the static SEO landing pages (/diagrams/*) and +// sitemap.xml. Runs after `vite build` and writes directly into dist/. +// +// The pages are plain, dependency-free HTML (inline CSS, inline SVG) so they +// are fast, fully crawlable, and independent of the SPA bundle. Vercel serves +// them via cleanUrls (dist/diagrams/foo.html → /diagrams/foo) ahead of the +// SPA rewrite, which only applies when no file matches. + +import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DIAGRAM_PAGES, SITE_URL } from './seo-content.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DIST = join(__dirname, '..', 'dist'); + +if (!existsSync(DIST)) { + console.error('dist/ not found, run `vite build` first.'); + process.exit(1); +} + +const BUILD_DATE = new Date().toISOString().slice(0, 10); + +// Inline right-arrow used on call-to-action links (in place of a literal arrow +// character). Inherits the link's color; small left margin for spacing. +const ARROW = ''; + +// ── helpers ────────────────────────────────────────────────────────────────── + +const esc = (s) => + String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +/** Render label text with _x subscripts / ^x superscripts as SVG tspans. */ +function svgLabel(text) { + let out = ''; + let i = 0; + // After a sub/superscript, the baseline reset is carried onto the next + // plain-text run (dy on real characters) rather than an empty whitespace + // tspan, which would render a stray space inside the label. + let pendingReset = null; + while (i < text.length) { + const ch = text[i]; + if ((ch === '_' || ch === '^') && i + 1 < text.length) { + let token = text[i + 1]; + let consumed = 2; + if (text[i + 1] === '{') { + const close = text.indexOf('}', i + 2); + if (close !== -1) { + token = text.slice(i + 2, close); + consumed = close - i + 1; + } + } + const dy = ch === '_' ? '3' : '-4'; + out += `${esc(token)}`; + pendingReset = ch === '_' ? '-3' : '4'; + i += consumed; + } else { + // Gather the whole plain-text run and emit it once, applying any + // pending baseline reset to it. + let j = i; + while (j < text.length && text[j] !== '_' && text[j] !== '^') j += 1; + const run = text.slice(i, j); + out += pendingReset !== null + ? `${esc(run)}` + : esc(run); + pendingReset = null; + i = j; + continue; + } + } + return out; +} + +/** Render the declarative diagram spec into an inline SVG. */ +function renderDiagramSvg(page) { + const { diagram, axes } = page; + if (!diagram) return ''; + const W = 520, H = 360, PAD = 52; + const sx = (x) => PAD + (x / 100) * (W - PAD - 24); + const sy = (y) => H - PAD - (y / 100) * (H - PAD - 28); + + let body = ''; + + for (const line of diagram.lines ?? []) { + const [x1, y1, x2, y2, color, label, dashed] = line; + body += ``; + if (label) { + body += `${svgLabel(label)}`; + } + } + + for (const curve of diagram.curves ?? []) { + const [x1, y1, cx, cy, x2, y2, color, label] = curve; + body += ``; + if (label) { + body += `${svgLabel(label)}`; + } + } + + for (const point of diagram.points ?? []) { + const [x, y, label] = point; + body += ``; + body += ``; + body += ``; + body += `${svgLabel(label)}`; + } + + return ` + + + + + + + + ${esc(axes[0])} + ${esc(axes[1])} + ${body} +`; +} + +const CSS = ` +:root{color-scheme:light} +*{margin:0;padding:0;box-sizing:border-box} +body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#111827;background:#fff;line-height:1.65;-webkit-font-smoothing:antialiased} +a{color:#2563eb;text-decoration:none} +a:hover{text-decoration:underline} +.wrap{max-width:820px;margin:0 auto;padding:0 24px} +header.site{position:sticky;top:0;background:rgba(255,255,255,.92);backdrop-filter:blur(12px);border-bottom:1px solid #f1f5f9;z-index:10} +header.site .inner{max-width:1080px;margin:0 auto;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;gap:16px} +.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:18px;color:#111827} +.brand:hover{text-decoration:none} +.brand .logo{width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,#2563eb,#4f46e5);display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px rgba(37,99,235,.25)} +nav.top{display:flex;align-items:center;gap:18px;font-size:14px} +nav.top a{color:#6b7280;font-weight:500} +nav.top a:hover{color:#111827;text-decoration:none} +.btn{display:inline-flex;align-items:center;gap:8px;background:#111827;color:#fff!important;padding:10px 18px;border-radius:10px;font-weight:600;font-size:14px} +.btn:hover{background:#1f2937;text-decoration:none!important} +.btn.primary{background:linear-gradient(90deg,#2563eb,#4f46e5);box-shadow:0 8px 22px rgba(37,99,235,.28);font-size:16px;padding:14px 26px;border-radius:12px} +.btn.primary:hover{filter:brightness(1.05)} +.btn.ghost{background:#fff;color:#374151!important;border:1px solid #e5e7eb} +.btn.ghost:hover{background:#f9fafb} +.crumbs{font-size:13px;color:#9ca3af;padding:26px 0 0} +.crumbs a{color:#9ca3af} +h1{font-size:clamp(30px,5vw,44px);line-height:1.12;letter-spacing:-.02em;margin:14px 0 18px;font-weight:800} +.lede{font-size:18px;color:#4b5563} +.cta-row{display:flex;flex-wrap:wrap;gap:14px;margin:28px 0 8px;align-items:center} +.free-note{font-size:13px;color:#6b7280} +.figure{margin:38px 0;padding:20px;border:1px solid #e2e8f0;border-radius:16px;box-shadow:0 14px 40px -18px rgba(15,23,42,.14);background:linear-gradient(180deg,#f8fafc60,#fff)} +.figure svg{width:100%;height:auto;display:block} +h2{font-size:24px;letter-spacing:-.01em;margin:44px 0 14px;font-weight:700} +p{margin:12px 0;color:#374151} +ul.what{list-style:none;margin:16px 0} +ul.what li{padding:10px 0 10px 30px;position:relative;color:#374151;border-bottom:1px solid #f8fafc} +ul.what li::before{content:"";position:absolute;left:4px;top:17px;width:9px;height:9px;border-radius:3px;background:linear-gradient(135deg,#2563eb,#4f46e5)} +ul.what b{color:#111827} +ol.steps{margin:16px 0 16px 0;counter-reset:step;list-style:none} +ol.steps li{counter-increment:step;position:relative;padding:10px 0 10px 44px;color:#374151} +ol.steps li::before{content:counter(step);position:absolute;left:0;top:10px;width:28px;height:28px;border-radius:9px;background:#eff6ff;color:#2563eb;font-weight:700;font-size:14px;display:flex;align-items:center;justify-content:center} +.tips{background:#fffbeb;border:1px solid #fde68a;border-radius:14px;padding:18px 22px;margin:18px 0} +.tips li{margin:8px 0 8px 18px;color:#78350f} +.faq details{border-bottom:1px solid #f1f5f9;padding:4px 0} +.faq summary{cursor:pointer;font-weight:600;padding:12px 0;color:#111827} +.faq p{padding:0 0 14px;color:#4b5563} +.related{display:flex;flex-wrap:wrap;gap:10px;margin:16px 0} +.related a{border:1px solid #e5e7eb;padding:8px 16px;border-radius:999px;font-size:14px;color:#374151;font-weight:500} +.related a:hover{border-color:#93c5fd;color:#2563eb;text-decoration:none} +.guarantee{margin:52px 0;background:linear-gradient(135deg,#ecfdf5,#f0fdfa);border:1px solid #a7f3d0;border-radius:18px;padding:28px;text-align:center} +.guarantee h2{margin:0 0 8px;font-size:22px} +.guarantee p{color:#065f46;max-width:560px;margin:8px auto 18px} +footer.site{border-top:1px solid #f1f5f9;margin-top:64px;padding:34px 0 44px;font-size:14px;color:#9ca3af} +footer.site .inner{max-width:1080px;margin:0 auto;padding:0 24px;display:flex;flex-wrap:wrap;gap:14px;align-items:center;justify-content:space-between} +footer.site a{color:#6b7280} +.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px;margin:30px 0} +.cards a{border:1px solid #e5e7eb;border-radius:14px;padding:18px;color:#111827;display:block} +.cards a:hover{border-color:#93c5fd;box-shadow:0 10px 26px -14px rgba(37,99,235,.35);text-decoration:none} +.cards .k{font-weight:700;margin-bottom:4px} +.cards .d{font-size:13.5px;color:#6b7280} +@media(max-width:640px){nav.top a.hide-sm{display:none}} +`; + +const LOGO_SVG = ``; + +function pageShell({ title, description, canonicalPath, jsonLd, bodyHtml }) { + return ` + + + + +${esc(title)} + + + + + + + + + + + + + +${jsonLd.map((obj) => ``).join('\n')} + + + +
    + +
    +${bodyHtml} +
    +
    + © ${new Date().getFullYear()} IB EconGraph AI, free & open source (AGPL-3.0). Built for IB Economics students and educators. + + All diagrams · + Pricing · + Compare · + GitHub · + Support + +
    +
    + +`; +} + +const softwareAppLd = { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'IB EconGraph AI', + description: 'Free, open-source AI-powered economics diagram editor for IB students and educators.', + applicationCategory: 'EducationalApplication', + operatingSystem: 'Web', + url: SITE_URL, + offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' }, + author: { '@type': 'Person', name: 'Sukarth Acharya' }, +}; + +function renderDiagramPage(page) { + const path = `/diagrams/${page.slug}`; + const jsonLd = [ + softwareAppLd, + { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL + '/' }, + { '@type': 'ListItem', position: 2, name: 'Diagrams', item: SITE_URL + '/diagrams' }, + { '@type': 'ListItem', position: 3, name: page.navTitle, item: SITE_URL + path }, + ], + }, + { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: page.faq.map(([q, a]) => ({ + '@type': 'Question', + name: q, + acceptedAnswer: { '@type': 'Answer', text: a }, + })), + }, + ]; + + const related = page.related + .map((slug) => { + const target = DIAGRAM_PAGES.find((p) => p.slug === slug); + return target ? `${esc(target.navTitle)}` : ''; + }) + .join(''); + + const bodyHtml = ` +
    +
    HomeDiagrams › ${esc(page.navTitle)}
    +

    ${esc(page.h1)}

    +

    ${esc(page.intro[0])}

    + +

    Free forever · no account needed · no watermark · exports as SVG, PNG & JPEG

    + +
    ${renderDiagramSvg(page)}
    + +

    ${esc(page.intro[1])}

    + +

    What the ${esc(page.keyword)} shows

    +

    ${esc(page.whatItShows.text)}

    +
      + ${page.whatItShows.bullets.map(([term, def]) => `
    • ${esc(term)}: ${esc(def)}
    • `).join('\n ')} +
    + +

    How to draw it in IB EconGraph AI

    +
      + ${page.howToDraw.map((step) => `
    1. ${esc(step)}
    2. `).join('\n ')} +
    + +

    IA & exam tips

    +
    +
      + ${page.iaTips.map((tip) => `
    • ${esc(tip)}
    • `).join('\n ')} +
    +
    + +

    Frequently asked questions

    +
    + ${page.faq.map(([q, a]) => `
    ${esc(q)}

    ${esc(a)}

    `).join('\n ')} +
    + +

    Related diagram makers

    + + +
    +

    Free, unlimited, forever.

    +

    Everything a student needs to finish their IA is free: unlimited diagrams, every tool, full-quality exports with no watermark, and unlimited AI generation with your own free API key.

    + Start drawing, it's free +
    +
    `; + + return pageShell({ + title: page.title, + description: page.metaDescription, + canonicalPath: path, + jsonLd, + bodyHtml, + }); +} + +function renderHubPage() { + const jsonLd = [ + softwareAppLd, + { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL + '/' }, + { '@type': 'ListItem', position: 2, name: 'Diagrams', item: SITE_URL + '/diagrams' }, + ], + }, + ]; + + const bodyHtml = ` +
    +
    Home › Diagrams
    +

    Every IB Economics diagram, drawable in seconds

    +

    Free, exam-ready diagram makers for the whole IB Economics syllabus, micro, macro, and international trade. Generate with AI or draw by hand, then export at full quality with no watermark.

    + +
    + ${DIAGRAM_PAGES.map((p) => `
    ${esc(p.navTitle)}
    ${esc(p.h1)}
    `).join('\n ')} +
    +
    +

    Free, unlimited, forever.

    +

    Everything a student needs to finish their IA is free: unlimited diagrams, every tool, full-quality exports with no watermark, and unlimited AI generation with your own free API key.

    + Start drawing, it's free +
    +
    `; + + return pageShell({ + title: 'IB Economics Diagram Makers: Free, AI-Powered, No Watermark | IB EconGraph AI', + description: + 'Free diagram makers for every IB Economics diagram: supply & demand, monopoly, externalities, tariffs, AD-AS, PPC and more. Draw or AI-generate, export watermark-free.', + canonicalPath: '/diagrams', + jsonLd, + bodyHtml, + }); +} + +function renderSitemap() { + // Only list URLs whose served HTML self-canonicalizes. /home, /editor and + // /settings are app UI that serve index.html (canonical → "/"), so listing + // them would submit homepage duplicates. /pricing and /compare are included + // because the SPA sets a matching per-route canonical (see App.tsx). + const urls = [ + { loc: '/', priority: '1.0', changefreq: 'weekly' }, + { loc: '/pricing', priority: '0.9', changefreq: 'monthly' }, + { loc: '/compare', priority: '0.8', changefreq: 'monthly' }, + { loc: '/diagrams', priority: '0.9', changefreq: 'weekly' }, + ...DIAGRAM_PAGES.map((p) => ({ loc: `/diagrams/${p.slug}`, priority: '0.8', changefreq: 'monthly' })), + { loc: '/privacy', priority: '0.3', changefreq: 'yearly' }, + { loc: '/terms', priority: '0.3', changefreq: 'yearly' }, + ]; + return ` + +${urls + .map( + (u) => ` + ${SITE_URL}${u.loc} + ${BUILD_DATE} + ${u.changefreq} + ${u.priority} + `, + ) + .join('\n')} + +`; +} + +// ── emit ───────────────────────────────────────────────────────────────────── + +mkdirSync(join(DIST, 'diagrams'), { recursive: true }); + +for (const page of DIAGRAM_PAGES) { + writeFileSync(join(DIST, 'diagrams', `${page.slug}.html`), renderDiagramPage(page)); +} +writeFileSync(join(DIST, 'diagrams.html'), renderHubPage()); +writeFileSync(join(DIST, 'sitemap.xml'), renderSitemap()); + +console.log(`Generated ${DIAGRAM_PAGES.length} diagram pages + hub + sitemap.xml into dist/`); diff --git a/scripts/seo-content.mjs b/scripts/seo-content.mjs new file mode 100644 index 0000000..da4367c --- /dev/null +++ b/scripts/seo-content.mjs @@ -0,0 +1,618 @@ +// Content for the prerendered SEO landing pages (one per diagram type). +// Rendered to static HTML by generate-seo-pages.mjs at build time. +// +// Writing guidelines: every page must be genuinely useful to an IB Economics +// student on its own (not doorway-page filler), unique, and specific to the +// diagram type. Keep claims about the product truthful: free, unlimited, +// no watermark, BYOK AI free, hosted AI on the Supporter plan. + +export const SITE_URL = 'https://ib-econgraph-ai.vercel.app'; + +/** + * diagram: simple declarative spec rendered as an inline SVG. + * lines: [x1, y1, x2, y2, color, label, dashed?] in a 0–100 coordinate space + * (y up); labels are placed at the line's end. + * points: [x, y, label] + * All pages share axes labelled by `axes` ([x, y]). + */ +export const DIAGRAM_PAGES = [ + { + slug: 'supply-and-demand', + keyword: 'supply and demand diagram', + navTitle: 'Supply & Demand', + title: 'Supply and Demand Diagram Maker: Free, No Watermark | IB EconGraph AI', + metaDescription: + 'Draw exam-ready supply and demand diagrams for IB Economics in seconds, free, unlimited, no watermark. Generate with AI or drag curves by hand, then export as SVG or PNG for your IA.', + h1: 'Supply and Demand Diagram Maker', + intro: [ + 'The supply and demand diagram is the workhorse of IB Economics: almost every microeconomics answer, from market equilibrium to government intervention, starts with these two curves. Examiners expect accurately drawn, fully labelled diagrams with equilibrium price and quantity clearly marked.', + 'IB EconGraph AI lets you draw one in seconds: describe the market in plain English and let AI plot mathematically consistent curves, or drag lines onto the canvas yourself. Export at full quality with no watermark, free, forever.', + ], + whatItShows: { + text: 'A standard market diagram plots price (P) on the vertical axis and quantity (Q) on the horizontal axis:', + bullets: [ + ['Demand curve (D)', 'downward-sloping, showing the inverse relationship between price and quantity demanded (law of demand).'], + ['Supply curve (S)', 'upward-sloping, showing that producers supply more at higher prices (law of supply).'], + ['Equilibrium (E)', 'the intersection of D and S, determining market price P* and quantity Q*, usually marked with dotted lines to both axes.'], + ['Shifts vs movements', 'a change in a determinant (income, costs, tastes) shifts the whole curve to D₁/S₁; a price change causes movement along a curve.'], + ['Consumer & producer surplus', 'the triangles between the curves and the equilibrium price line, often shaded in evaluation answers.'], + ], + }, + howToDraw: [ + 'Open the editor and pick the "Supply & Demand" template from the Component Library, or type "supply and demand equilibrium for the coffee market" in the AI panel.', + 'Label both axes (Price / Quantity) and each curve, the editor supports subscripts like D₁ using underscore notation (D_1).', + 'Mark the equilibrium with an annotated point; enable dotted lines so P* and Q* project onto both axes.', + 'To show a shift, duplicate the curve, drag it left or right, and relabel (e.g. D to D₁); add arrows or a second equilibrium point E₁.', + 'Shade consumer or producer surplus with the fill tool if your answer discusses welfare, then export as SVG or PNG.', + ], + iaTips: [ + 'For an IA commentary, always draw the diagram specific to your article, label the actual good ("Market for lithium") rather than a generic "Good X".', + 'Use a full title and figure caption (e.g. "Figure 1: Market for lithium after the export ban"), the editor has a dedicated caption field.', + 'IB markschemes reward accurate labelling above artistic quality: axes, curves, equilibrium values, and the direction of any shift must all be explicit.', + ], + faq: [ + ['Is this supply and demand graph maker really free?', 'Yes. Unlimited diagrams, every drawing tool, and full-quality SVG/PNG/JPEG export with no watermark are free forever. AI generation is also free with your own Google AI Studio key.'], + ['Can the AI draw curve shifts?', 'Yes, ask for e.g. "show demand increasing for electric cars" and it plots the original curve, the shifted curve, and both equilibria with consistent intersection coordinates.'], + ['What export formats can I use in my IA?', 'SVG (vector, scales perfectly in documents), PNG, and JPEG. All at full quality with no watermark on the free plan.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 10, 90, 90, '#3b82f6', 'S'], + ], + points: [[50, 50, 'E']], + }, + related: ['price-ceilings-and-floors', 'tax-incidence', 'subsidy-diagram'], + }, + { + slug: 'monopoly-diagram', + keyword: 'monopoly diagram', + navTitle: 'Monopoly', + title: 'Monopoly Diagram Maker (MR, MC, DWL): Free IB Economics Tool', + metaDescription: + 'Create accurate IB monopoly diagrams with MR below AR, profit maximisation at MC = MR, abnormal profit and deadweight loss, free, AI-assisted, exportable with no watermark.', + h1: 'Monopoly Diagram Maker', + intro: [ + 'The monopoly diagram is one of the hardest in the IB course to draw correctly: marginal revenue must sit below the demand (AR) curve with twice the slope, output is read at MC = MR, but price is read up on the demand curve. Getting these relationships wrong costs marks instantly.', + 'IB EconGraph AI knows those rules. Ask for "monopoly with abnormal profit and deadweight loss" and it plots D, MR, MC and ATC with mathematically consistent intersections, or build it yourself from the monopoly template.', + ], + whatItShows: { + text: 'The profit-maximising monopolist diagram contains:', + bullets: [ + ['Demand / AR curve', 'downward-sloping, the monopolist is a price maker facing the whole market demand.'], + ['Marginal revenue (MR)', 'below AR, falling twice as steeply; drawn dashed in most textbooks.'], + ['Profit maximisation', 'output Qₘ where MC = MR; price Pₘ read vertically up to the demand curve.'], + ['Abnormal profit', 'the rectangle between Pₘ and ATC at Qₘ, shade it when the question asks about profits.'], + ['Deadweight loss', 'the welfare triangle between the demand curve, MC, and Qₘ, showing allocative inefficiency (P > MC).'], + ], + }, + howToDraw: [ + 'Start from the "Monopoly" template in the Component Library (D, MR and MC pre-arranged), or prompt the AI with the exact scenario you need.', + 'Find MC = MR and drop an annotated point; project the dotted line down for Qₘ and up to the demand curve for Pₘ.', + 'Add the ATC curve if your answer discusses abnormal profit, and shade the profit rectangle with the fill tool.', + 'For welfare analysis, shade the DWL triangle between Qₘ and the allocatively efficient output where P = MC.', + 'Label everything, Pₘ, Qₘ, and the competitive comparison point if you\'re contrasting with perfect competition.', + ], + iaTips: [ + 'Paper 1 part (b) questions on monopoly almost always need the DWL triangle, practice shading it cleanly.', + 'When comparing with perfect competition, add P꜀ and Q꜀ on the same diagram rather than drawing two separate ones.', + 'Natural monopoly questions need a continuously falling ATC, use the bezier curve tool to get the shape right.', + ], + faq: [ + ['Does the AI get MR below AR right?', 'Yes, the generator is instructed to keep MR below the demand curve with the correct slope relationship, and you can drag any curve to fine-tune it.'], + ['Can I shade abnormal profit and DWL on the same diagram?', 'Yes. The fill tool lets you shade any polygon; use different colours (e.g. green for profit, red for DWL) from the colour palette.'], + ['Is the export watermarked?', 'No. Full-quality SVG, PNG, and JPEG exports are free with no watermark, that is part of the free-forever guarantee.'], + ], + axes: ['Quantity (Q)', 'Price, Costs (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D=AR'], + [10, 90, 55, 10, '#ec4899', 'MR', true], + [10, 15, 85, 88, '#3b82f6', 'MC'], + ], + points: [[35, 44, 'MC=MR'], [35, 68, 'P_m']], + }, + related: ['perfect-competition', 'supply-and-demand', 'negative-externalities'], + }, + { + slug: 'negative-externalities', + keyword: 'negative externality diagram', + navTitle: 'Negative Externalities', + title: 'Negative Externality Diagram Maker (MSC/MPC): Free IB Tool', + metaDescription: + 'Draw negative production and consumption externality diagrams with MSC, MPC, welfare loss triangles and corrective taxes, free, exam-ready, no watermark. Built for IB Economics.', + h1: 'Negative Externality Diagram Maker', + intro: [ + 'Externality diagrams dominate IB market-failure questions and real-world IA commentaries, carbon taxes, congestion charges, sugar levies. The examiner wants to see marginal social cost diverging from marginal private cost, the welfare loss triangle pointing at the socially optimal output, and any corrective policy drawn in.', + 'With IB EconGraph AI you can generate a complete negative production externality diagram from one sentence, then adjust the divergence, shade the welfare loss, and add a tax shift, all with exact, consistent intersection points.', + ], + whatItShows: { + text: 'A negative production externality diagram (e.g. a polluting factory) shows:', + bullets: [ + ['MPC curve', 'the private supply curve, costs the producer actually pays.'], + ['MSC curve', 'above MPC; the vertical gap is the external cost imposed on third parties.'], + ['Market equilibrium (Q₁)', 'where MPC meets demand (MPB), the free-market outcome with overproduction.'], + ['Social optimum (Q*)', 'where MSC meets MSB, the allocatively efficient output.'], + ['Welfare loss', 'the triangle between MSC and MPB from Q* to Q₁, showing the deadweight loss of overproduction.'], + ], + }, + howToDraw: [ + 'Prompt the AI with e.g. "negative production externality from a coal plant with welfare loss shaded", or draw MPC first and duplicate it upward for MSC.', + 'Keep MSC parallel to MPC (a constant marginal external cost) unless your analysis argues the externality grows with output.', + 'Mark both quantities: the market output Q₁ (D = MPC) and the social optimum Q* (D = MSC), with dotted lines to the axes.', + 'Shade the welfare loss triangle between the two quantities using the fill tool.', + 'For policy evaluation, shift MPC up towards MSC to show a Pigouvian tax internalising the externality.', + ], + iaTips: [ + 'Most IA market-failure commentaries use this exact diagram, customise the labels to your article ("MSC of plastic production") to hit the "application" criterion.', + 'Distinguish production vs consumption externalities: consumption ones diverge MPB/MSB on the demand side instead.', + 'When evaluating a tax, note on the diagram whether it fully closes the MPC–MSC gap; partial internalisation is a strong evaluation point.', + ], + faq: [ + ['Can it draw consumption externalities too?', 'Yes, ask for a negative consumption externality (e.g. cigarettes) and it diverges MPB below MSB instead, with the welfare loss in the right place.'], + ['How do I show a corrective (Pigouvian) tax?', 'Duplicate the MPC curve and shift it up by the tax; the new equilibrium moves toward the social optimum. The tax-incidence template also helps here.'], + ['Is this suitable for my IA?', 'Yes, export vector SVGs that stay sharp at any size in your commentary, with your article-specific labels and figure caption.'], + ], + axes: ['Quantity (Q)', 'Costs / Benefits (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'MPB'], + [10, 10, 90, 90, '#3b82f6', 'MPC'], + [10, 30, 78, 95, '#648d49', 'MSC'], + ], + points: [[50, 50, 'Q_1'], [40, 55, 'Q^*']], + }, + related: ['positive-externalities', 'tax-incidence', 'subsidy-diagram'], + }, + { + slug: 'positive-externalities', + keyword: 'positive externality diagram', + navTitle: 'Positive Externalities', + title: 'Positive Externality Diagram Maker (MSB/MPB): Free IB Tool', + metaDescription: + 'Create positive consumption and production externality diagrams with MSB above MPB, underconsumption, welfare loss and subsidy corrections, free and exam-ready for IB Economics.', + h1: 'Positive Externality Diagram Maker', + intro: [ + 'Vaccinations, education, public transport, positive externality diagrams appear across IB Paper 1 and endless IA articles. The logic mirrors negative externalities but flipped: marginal social benefit sits above marginal private benefit, the market underconsumes, and government subsidies push output toward the social optimum.', + 'Generate the whole diagram with AI or assemble it from templates, with the welfare loss triangle and subsidy shift drawn precisely where they belong.', + ], + whatItShows: { + text: 'A positive consumption externality diagram (e.g. vaccination) shows:', + bullets: [ + ['MPB curve', 'the market demand curve, benefits captured by the individual consumer.'], + ['MSB curve', 'above MPB; the gap is the external benefit enjoyed by third parties (herd immunity, a more educated workforce).'], + ['Market equilibrium (Q₁)', 'where MPB meets supply (MSC), the free market underconsumes.'], + ['Social optimum (Q*)', 'where MSB meets MSC, at a higher quantity than the market delivers.'], + ['Welfare loss', 'the triangle between MSB and MSC from Q₁ to Q*, representing the forgone net benefit.'], + ], + }, + howToDraw: [ + 'Ask the AI for "positive consumption externality of vaccines with welfare loss" or start with a supply-and-demand template and add a second, higher demand curve labelled MSB.', + 'Mark Q₁ at MPB = MSC and Q* at MSB = MSC with dotted projection lines.', + 'Shade the welfare-loss triangle between the two quantities.', + 'To show a subsidy, shift the supply curve down (or MPB up for demand-side policies like advertising) and mark the new equilibrium.', + 'Add a caption tying the diagram to the specific merit good you\'re analysing.', + ], + iaTips: [ + 'State explicitly on the diagram which curves diverge, the IB rewards "MSB > MPB at every quantity" style annotations.', + 'Pair the diagram with the subsidy diagram when your article covers government support for merit goods.', + 'Evaluation gold: does the subsidy close the whole MPB–MSB gap? Draw a partial shift and discuss.', + ], + faq: [ + ['What is the difference between production and consumption positive externalities?', 'Production ones (e.g. R&D spillovers) diverge the cost curves (MSC below MPC); consumption ones (e.g. education) diverge the benefit curves (MSB above MPB). The AI handles both if you name the case.'], + ['Can I show government subsidies on the same diagram?', 'Yes, duplicate and shift the supply curve downward by the subsidy, then mark the new quantity against Q*.'], + ['Do I need an account?', 'No. The editor, templates, AI with your own key, and full-quality exports all work without signing in.'], + ], + axes: ['Quantity (Q)', 'Costs / Benefits (P)'], + diagram: { + lines: [ + [10, 80, 85, 10, '#ef4444', 'MPB'], + [18, 95, 90, 22, '#648d49', 'MSB'], + [10, 10, 90, 90, '#3b82f6', 'MSC'], + ], + points: [[45, 45, 'Q_1'], [56, 56, 'Q^*']], + }, + related: ['negative-externalities', 'subsidy-diagram', 'supply-and-demand'], + }, + { + slug: 'price-ceilings-and-floors', + keyword: 'price ceiling and price floor diagram', + navTitle: 'Price Controls', + title: 'Price Ceiling & Price Floor Diagram Maker: Free IB Tool', + metaDescription: + 'Draw price ceiling (maximum price) and price floor (minimum price) diagrams with shortages, surpluses and welfare effects, free, unlimited, watermark-free. Made for IB Economics.', + h1: 'Price Ceiling & Price Floor Diagram Maker', + intro: [ + 'Rent controls, food price caps, minimum wages, agricultural price supports, price control diagrams turn up in every IB paper and countless IA commentaries. The key skill is placing the controlled price on the correct side of equilibrium and reading off the resulting shortage or surplus.', + 'IB EconGraph AI draws the control line, marks Qd and Qs at the controlled price, and labels the shortage or surplus gap for you, or gives you a clean canvas to construct it manually.', + ], + whatItShows: { + text: 'Price control diagrams start from ordinary supply and demand, then add a horizontal price line:', + bullets: [ + ['Price ceiling (maximum price)', 'set below equilibrium, e.g. rent control. Quantity demanded exceeds quantity supplied, creating a shortage (excess demand).'], + ['Price floor (minimum price)', 'set above equilibrium, e.g. minimum wage, farm supports. Quantity supplied exceeds quantity demanded, creating a surplus (excess supply).'], + ['Qd and Qs', 'read where the control line crosses each curve; the horizontal gap between them is the shortage/surplus, label it explicitly.'], + ['Welfare effects', 'shade the deadweight loss and the transfers between consumers and producers for evaluation answers.'], + ], + }, + howToDraw: [ + 'Generate "price ceiling below equilibrium in the rental market showing the shortage" with AI, or add a horizontal line to the supply-and-demand template.', + 'Place the ceiling below (floor above) the equilibrium, the most common student error is putting it on the wrong side, where it has no effect.', + 'Drop annotated points where the price line crosses D and S; label Qd and Qs on the axis.', + 'Draw a labelled bracket or arrow for the shortage/surplus gap using the line and text tools.', + 'Shade the DWL triangle if the question asks about welfare or efficiency.', + ], + iaTips: [ + 'A non-binding control (ceiling above equilibrium) is a legitimate evaluation point, you can draw both cases side by side in one project.', + 'For minimum wage articles, relabel the axes (Wage rate / Quantity of labour), double-click any label to edit it.', + 'Discussing black markets? Mark the price consumers would pay for the restricted quantity Qs up on the demand curve.', + ], + faq: [ + ['Which side of equilibrium does a price ceiling go?', 'A binding price ceiling sits below equilibrium (it caps the price), creating a shortage. A binding floor sits above, creating a surplus. The AI places them correctly from your description.'], + ['Can I show both a ceiling and a floor?', 'Yes, projects let you keep multiple related graphs together, or you can place both lines on one canvas for a comparison diagram.'], + ['Can I label the shortage gap?', 'Yes, use the text label tool for "shortage = Qd − Qs" and the line tool for the bracket arrows.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 10, 90, 90, '#3b82f6', 'S'], + [10, 35, 90, 35, '#f59e0b', 'P_max'], + ], + points: [[35, 35, 'Q_s'], [65, 35, 'Q_d']], + }, + related: ['supply-and-demand', 'tax-incidence', 'subsidy-diagram'], + }, + { + slug: 'tariff-diagram', + keyword: 'tariff diagram', + navTitle: 'Tariffs & Quotas', + title: 'Tariff Diagram Maker (World Price, Welfare Loss): Free IB Tool', + metaDescription: + 'Draw IB international trade tariff diagrams with world supply, domestic supply, tariff revenue and the two deadweight loss triangles, free, precise, watermark-free exports.', + h1: 'Tariff Diagram Maker', + intro: [ + 'The tariff diagram is the most detail-dense diagram in the IB course: domestic supply and demand, a horizontal world supply line, a raised world-supply-plus-tariff line, and up to six labelled quantities with revenue rectangles and two welfare-loss triangles. Drawing it by hand under time pressure is brutal.', + 'IB EconGraph AI generates the full structure with consistent geometry, and the shading tools make the revenue rectangle and DWL triangles quick to add and easy to distinguish.', + ], + whatItShows: { + text: 'The small-country tariff diagram shows:', + bullets: [ + ['Domestic S and D', 'the home market curves determining the autarky equilibrium.'], + ['World supply (Sw)', 'a horizontal line at the world price Pw, the country imports the gap between Qd and Qs at that price.'], + ['Sw + tariff', 'a parallel horizontal line at Pw + t; imports shrink as domestic output expands and consumption contracts.'], + ['Government revenue', 'the rectangle: tariff × post-tariff import quantity.'], + ['Welfare losses', 'two triangles, the production inefficiency (higher-cost domestic output) and the consumption loss (forgone consumer surplus).'], + ], + }, + howToDraw: [ + 'Prompt: "tariff diagram for a small country importing steel, show government revenue and both deadweight loss triangles".', + 'Check the four quantities on the x-axis (Qs, Qs\', Qd\', Qd) are in the right order and labelled.', + 'Shade the revenue rectangle between the two horizontal lines and the post-tariff import quantities.', + 'Shade the two DWL triangles either side of the revenue rectangle in a different colour.', + 'Add a caption naming the good and the tariff, and export as SVG for your document.', + ], + iaTips: [ + 'Trade-war and protectionism articles are IA staples, this diagram plus a stakeholder analysis (consumers, producers, government, foreign exporters) is a complete commentary skeleton.', + 'A quota uses the same structure but with no revenue rectangle for the government (the quota rent may go to foreign producers), a strong evaluation contrast.', + 'Keep colours consistent: one colour for welfare losses, another for revenue, so the examiner can read it at a glance.', + ], + faq: [ + ['Does it handle quota diagrams too?', 'Yes, describe a quota and the AI draws the restricted-imports structure; or adapt the tariff diagram manually by replacing the tariff line.'], + ['Can I label all six quantities?', 'Yes, annotated points project dotted lines onto the axes, and every label supports subscripts (Q_1, Q_2 …).'], + ['Why are there two deadweight loss triangles?', 'One is the production inefficiency (domestic firms produce units that the world could supply more cheaply); the other is lost consumer surplus from reduced consumption. The page diagram shows both positions.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 10, 90, 90, '#3b82f6', 'S'], + [10, 30, 90, 30, '#64748b', 'S_w'], + [10, 45, 90, 45, '#f59e0b', 'S_w+t'], + ], + points: [], + }, + related: ['exchange-rate-diagram', 'supply-and-demand', 'tax-incidence'], + }, + { + slug: 'ad-as-diagram', + keyword: 'AD-AS diagram', + navTitle: 'AD–AS Model', + title: 'AD-AS Diagram Maker (Keynesian & Monetarist): Free IB Tool', + metaDescription: + 'Draw AD-AS diagrams for IB macro, monetarist/new-classical LRAS, Keynesian AS, demand-side and supply-side shocks, output gaps, free with watermark-free exports.', + h1: 'AD–AS Diagram Maker', + intro: [ + 'Aggregate demand–aggregate supply diagrams carry the whole IB macroeconomics syllabus: inflation, unemployment, growth, and every fiscal or monetary policy question. You need both versions, the monetarist/new-classical model with a vertical LRAS, and the Keynesian AS curve with its flat, curved, and vertical sections.', + 'IB EconGraph AI draws both. The bezier curve tool produces a clean Keynesian AS shape that\'s notoriously hard to sketch by hand, and the AI understands prompts like "deflationary gap in the Keynesian model".', + ], + whatItShows: { + text: 'The AD–AS framework plots average price level against real GDP:', + bullets: [ + ['AD curve', 'downward-sloping: C + I + G + (X − M) at each price level.'], + ['SRAS', 'upward-sloping short-run aggregate supply based on sticky input costs.'], + ['LRAS (monetarist)', 'vertical at potential output Yp, output returns there in the long run.'], + ['Keynesian AS', 'flat at low output (spare capacity), curving upward, vertical at full capacity, equilibria below Yp can persist.'], + ['Output gaps', 'deflationary (recessionary) gaps left of Yp; inflationary gaps to the right.'], + ], + }, + howToDraw: [ + 'Tell the AI which school you need: "monetarist AD-AS with a short-run inflationary gap" vs "Keynesian AS with equilibrium below full employment".', + 'For the Keynesian curve, use a bezier curve: start flat, add a control point to bend it up into the vertical section.', + 'Mark Yp with a vertical dashed line and label the gap between Y₁ and Yp explicitly.', + 'Show policy responses by shifting AD (fiscal/monetary) or SRAS/LRAS (supply-side) and adding the new equilibrium.', + 'Relabel axes as "Average price level" and "Real GDP (Y)", double-click any label to edit.', + ], + iaTips: [ + 'Macro IA commentaries score well when the diagram shows the specific gap from your article (e.g. "Japan\'s deflationary gap") rather than a generic model.', + 'Paper 1: choose the model that matches your argument, using the Keynesian AS to discuss persistent unemployment is a classic top-band move.', + 'Always label the price level change (PL₁ to PL₂) as well as output, half the marks are on the vertical axis.', + ], + faq: [ + ['Can it draw the Keynesian AS curve shape?', 'Yes, the AI produces the three-section shape with a bezier curve, and you can drag the control points to adjust the curvature precisely.'], + ['How do I show stagflation?', 'Shift SRAS left: the new equilibrium has a higher price level and lower real output. Prompt the AI with "stagflation from an oil price shock".'], + ['Does it work for exchange-rate or Phillips-curve style axes?', 'Axes and labels are fully editable, so any two-axis macro diagram is drawable manually even when there is no dedicated template.'], + ], + axes: ['Real GDP (Y)', 'Price level'], + diagram: { + lines: [ + [10, 80, 80, 15, '#ef4444', 'AD'], + [15, 12, 88, 85, '#3b82f6', 'SRAS'], + [65, 5, 65, 95, '#64748b', 'LRAS'], + ], + points: [[52, 43, 'Y_1']], + }, + related: ['exchange-rate-diagram', 'ppc-diagram', 'supply-and-demand'], + }, + { + slug: 'perfect-competition', + keyword: 'perfect competition diagram', + navTitle: 'Perfect Competition', + title: 'Perfect Competition Diagrams (Firm & Industry): Free IB Tool', + metaDescription: + 'Draw side-by-side industry and firm diagrams for perfect competition, short-run profit/loss and long-run equilibrium at minimum ATC, free IB Economics diagram maker, no watermark.', + h1: 'Perfect Competition Diagram Maker', + intro: [ + 'Perfect competition answers usually need two linked diagrams: the industry (market supply and demand setting price) and the individual firm (a horizontal P = AR = MR line against MC and ATC). Keeping the price line at exactly the same height across both panels is what examiners look for first.', + 'With IB EconGraph AI you can generate each panel and keep them in one project, using the horizontal-line template for the firm\'s demand curve and precise point snapping for the tangency conditions.', + ], + whatItShows: { + text: 'The two-panel perfect competition model shows:', + bullets: [ + ['Industry panel', 'market S and D determine the equilibrium price P*.'], + ['Firm panel', 'the firm takes P* as given, a horizontal line labelled P = AR = MR.'], + ['Profit maximisation', 'output where MC cuts MR from below.'], + ['Short-run abnormal profit/loss', 'the rectangle between price and ATC at the chosen output.'], + ['Long-run equilibrium', 'entry/exit shifts industry supply until P = minimum ATC and firms earn normal profit only.'], + ], + }, + howToDraw: [ + 'Create one graph for the industry (supply & demand template) and one for the firm within the same project.', + 'In the firm panel, add a horizontal "Price Line" from the Component Library and label it P = AR = MR at the industry price.', + 'Add MC and ATC bezier curves; profit-maximising output is where MC crosses the price line.', + 'Shade the profit or loss rectangle between the price line and ATC.', + 'For the long run, drag ATC until its minimum is tangent to the price line, snapping makes the tangency exact.', + ], + iaTips: [ + 'Draw the two panels with identical vertical scales so the shared price line reads clearly.', + 'Short-run loss diagrams (P below ATC but above AVC) are a common discriminator question, keep an AVC curve handy in a saved template.', + 'In "evaluate whether perfect competition is efficient" essays, mark both allocative (P = MC) and productive (min ATC) efficiency points on the firm diagram.', + ], + faq: [ + ['Can I draw the firm and industry side by side?', 'Each graph is one canvas, but projects keep the two panels together, and consistent export sizes make them easy to place side by side in a document.'], + ['How do I make ATC tangent to the price line?', 'Use point snapping, drag the ATC minimum onto the price line and the editor snaps the tangency point precisely.'], + ['Does the AI know P = AR = MR?', 'Yes, asking for "perfectly competitive firm in long-run equilibrium" produces the horizontal price line tangent to minimum ATC.'], + ], + axes: ['Quantity (Q)', 'Price, Costs (P)'], + diagram: { + lines: [ + [10, 55, 90, 55, '#f59e0b', 'P=AR=MR'], + ], + curves: [ + [10, 60, 40, 15, 90, 90, '#22c55e', 'MC'], + [10, 85, 50, 40, 90, 80, '#8b5cf6', 'ATC'], + ], + points: [[62, 55, 'Q^*']], + }, + related: ['monopoly-diagram', 'supply-and-demand', 'ppc-diagram'], + }, + { + slug: 'ppc-diagram', + keyword: 'PPC diagram', + navTitle: 'PPC / PPF', + title: 'PPC Diagram Maker (Production Possibilities Curve): Free IB Tool', + metaDescription: + 'Draw production possibilities curves for IB Economics, opportunity cost, scarcity, actual vs potential growth, efficiency points, free PPC/PPF diagram maker with clean exports.', + h1: 'PPC / PPF Diagram Maker', + intro: [ + 'The production possibilities curve is the first diagram in the IB course and a favourite for short Paper 1 questions: scarcity, choice, opportunity cost, and the difference between actual and potential growth all live on this one curve.', + 'IB EconGraph AI\'s bezier tool draws the classic concave-to-origin bow shape smoothly, with labelled points inside, on, and outside the frontier, plus shifted curves for economic growth.', + ], + whatItShows: { + text: 'The PPC plots the maximum combinations of two goods an economy can produce:', + bullets: [ + ['The frontier', 'concave to the origin because resources are not equally suited to both goods (increasing opportunity cost).'], + ['Points on the curve', 'productive efficiency, all resources fully employed.'], + ['Points inside', 'unemployment or inefficiency (e.g. a recession).'], + ['Points outside', 'currently unattainable, reachable only through growth.'], + ['Outward shifts', 'potential growth from more/better resources or technology; movements from inside toward the curve are actual growth.'], + ], + }, + howToDraw: [ + 'Draw a bezier curve from the y-axis to the x-axis and drag the control point outward for the concave bow shape.', + 'Label the axes with your two goods (e.g. "Capital goods" and "Consumer goods").', + 'Add annotated points: A and B on the curve, C inside (unemployment), D outside (unattainable).', + 'For growth questions, duplicate the curve and drag it outward, label PPC₁ and PPC₂.', + 'A straight-line PPC (constant opportunity cost) is just the line tool, useful for comparative advantage questions.', + ], + iaTips: [ + 'Use arrows between labelled points to show the story: C to A is actual growth, curve shift is potential growth.', + 'For opportunity cost questions, mark the movement along the curve and annotate how much of one good is given up.', + 'Asymmetric shifts (pivot on one axis) show growth biased toward one sector, a subtle detail that impresses examiners.', + ], + faq: [ + ['Can I draw both straight and curved PPCs?', 'Yes, the line tool gives constant opportunity cost, the bezier tool gives the standard concave frontier.'], + ['How do I show economic growth?', 'Duplicate the curve and drag it outward (or ask the AI for "PPC with outward shift showing potential growth").'], + ['Is this free for classroom use?', 'Completely, teachers and students can use everything without accounts or licences, and the project is MIT open source.'], + ], + axes: ['Consumer goods', 'Capital goods'], + diagram: { + curves: [ + [10, 85, 60, 75, 85, 10, '#3b82f6', 'PPC'], + ], + points: [[45, 68, 'A'], [30, 40, 'B']], + }, + related: ['ad-as-diagram', 'supply-and-demand', 'perfect-competition'], + }, + { + slug: 'tax-incidence', + keyword: 'tax incidence diagram', + navTitle: 'Indirect Taxes', + title: 'Indirect Tax & Tax Incidence Diagram Maker: Free IB Tool', + metaDescription: + 'Draw specific and ad valorem tax diagrams with consumer/producer incidence, government revenue and deadweight loss, free IB Economics tool with exact intersections and clean exports.', + h1: 'Indirect Tax & Tax Incidence Diagram Maker', + intro: [ + 'Indirect tax diagrams demand precision: the supply curve shifts up by exactly the tax, the new equilibrium splits the burden between consumers and producers, and the revenue rectangle plus DWL triangle must sit in exactly the right cells. Elasticity determines who pays more, the analytical heart of the question.', + 'IB EconGraph AI keeps the geometry consistent (the vertical gap between S and S+tax stays equal to the tax) and the shading tools make incidence areas unambiguous.', + ], + whatItShows: { + text: 'A specific (per-unit) tax diagram shows:', + bullets: [ + ['S and S + tax', 'the supply curve shifts vertically upward by the tax per unit (parallel for a specific tax, diverging for ad valorem).'], + ['New equilibrium', 'higher consumer price Pc, lower quantity Qt; producers receive Pp = Pc − tax.'], + ['Consumer incidence', 'the rectangle between the original price P* and Pc across Qt.'], + ['Producer incidence', 'the rectangle between P* and Pp across Qt.'], + ['Government revenue and DWL', 'revenue = tax × Qt (both incidence rectangles combined); the welfare-loss triangle sits between Qt and Q*.'], + ], + }, + howToDraw: [ + 'Use the "Tax Incidence" template, or prompt: "specific tax on cigarettes showing incidence on consumers and producers".', + 'Verify the vertical distance between S and S+tax equals the tax everywhere, drag with snapping if you adjust manually.', + 'Mark P*, Pc, and Pp with dotted lines; label Qt and Q* on the quantity axis.', + 'Shade consumer incidence and producer incidence in different colours, then the DWL triangle.', + 'For elasticity analysis, flatten or steepen the demand curve and watch the incidence split change, great for screenshots of both cases.', + ], + iaTips: [ + 'Sugar taxes, fuel duties, and tobacco excises are perennial IA topics, this diagram plus elasticity commentary is the expected core.', + 'PED vs PES rule: the more inelastic side bears more of the tax. Draw two versions to demonstrate it rather than just asserting it.', + 'Ad valorem taxes pivot the supply curve rather than shifting it in parallel, mention and draw the difference for top-band analysis.', + ], + faq: [ + ['Can it draw ad valorem taxes?', 'Yes, ask for an ad valorem (percentage) tax and the shifted supply curve diverges from the original instead of staying parallel.'], + ['How is a subsidy different?', 'A subsidy shifts supply down by the subsidy per unit, see the dedicated subsidy diagram page for the mirrored analysis.'], + ['Can I show government revenue?', 'Yes, shade the rectangle (tax × new quantity) with the fill tool; split it into the consumer and producer portions with two colours.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 10, 90, 90, '#3b82f6', 'S'], + [10, 30, 70, 90, '#3b82f6', 'S+tax', true], + ], + points: [[50, 50, 'E'], [40, 60, 'E_1']], + }, + related: ['subsidy-diagram', 'negative-externalities', 'price-ceilings-and-floors'], + }, + { + slug: 'subsidy-diagram', + keyword: 'subsidy diagram', + navTitle: 'Subsidies', + title: 'Subsidy Diagram Maker (IB Economics): Free, No Watermark', + metaDescription: + 'Draw subsidy diagrams with the supply shift, price fall, government cost rectangle and welfare analysis, free IB Economics diagram generator with AI assistance.', + h1: 'Subsidy Diagram Maker', + intro: [ + 'Subsidy diagrams mirror tax diagrams: supply shifts down by the per-unit subsidy, consumers pay less, producers receive more, and the government cost rectangle spans the entire subsidy times the new quantity. IB questions love asking who gains more, and the answer again comes down to relative elasticities.', + 'Generate the complete diagram from a one-line prompt, or shift a duplicated supply curve down with drag-and-snap precision.', + ], + whatItShows: { + text: 'A per-unit subsidy diagram shows:', + bullets: [ + ['S and S − subsidy', 'the supply curve shifts vertically down by the subsidy per unit.'], + ['New equilibrium', 'quantity rises to Qs; consumers pay the lower Pc while producers receive Pp = Pc + subsidy.'], + ['Government cost', 'the rectangle subsidy × Qs, usually the largest area on the diagram.'], + ['Consumer and producer gains', 'split of the subsidy benefit determined by relative elasticities.'], + ['Welfare loss', 'the small triangle beyond Q* where the marginal cost of extra output exceeds its marginal benefit.'], + ], + }, + howToDraw: [ + 'Prompt the AI with "subsidy for solar panels showing government cost and the price received by producers".', + 'Keep the vertical gap between the two supply curves constant, it equals the subsidy per unit.', + 'Mark three prices: original P*, consumer price Pc, and producer price Pp, all with dotted lines.', + 'Shade the government cost rectangle between Pc and Pp across the new quantity Qs.', + 'For welfare evaluation, shade the DWL triangle to the right of the original equilibrium.', + ], + iaTips: [ + 'Renewable energy and agricultural subsidy articles are IA classics, pair this diagram with an opportunity-cost evaluation of the government spending.', + 'Show explicitly that Pp − Pc equals the subsidy, annotating that vertical distance earns analysis marks.', + 'For merit goods, combine with the positive externality diagram: the subsidy is the policy that closes the MPB–MSB gap.', + ], + faq: [ + ['Which direction does supply shift for a subsidy?', 'Down (right) by the subsidy per unit, production is cheaper at every output level. The AI handles the geometry automatically.'], + ['How do I show who benefits more?', 'Compare the consumer gain (P* − Pc) with the producer gain (Pp − P*): the more inelastic side captures more. Draw steep vs flat demand versions to demonstrate.'], + ['Can I export this for my IA at high quality?', 'Yes, SVG, PNG, and JPEG exports are full quality and watermark-free, free forever.'], + ], + axes: ['Quantity (Q)', 'Price (P)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D'], + [10, 25, 90, 95, '#3b82f6', 'S'], + [18, 10, 90, 72, '#22c55e', 'S-sub', true], + ], + points: [[47, 55, 'E'], [58, 46, 'E_1']], + }, + related: ['tax-incidence', 'positive-externalities', 'price-ceilings-and-floors'], + }, + { + slug: 'exchange-rate-diagram', + keyword: 'exchange rate diagram', + navTitle: 'Exchange Rates', + title: 'Exchange Rate Diagram Maker (Currency S&D): Free IB Tool', + metaDescription: + 'Draw floating exchange rate diagrams, currency supply and demand, appreciation and depreciation shifts, central bank intervention, free IB Economics diagram maker.', + h1: 'Exchange Rate Diagram Maker', + intro: [ + 'Exchange rate diagrams apply supply and demand to a currency market: the price axis becomes the exchange rate (e.g. USD per EUR) and the quantity axis the quantity of currency traded. Appreciations and depreciations are just demand and supply shifts, but mislabelling the axes is the classic way to lose easy marks.', + 'IB EconGraph AI relabels everything for a currency market from a single prompt and shifts the right curve for your scenario, whether it\'s rising interest rates, import demand, or central bank intervention.', + ], + whatItShows: { + text: 'A floating exchange rate diagram for, say, the euro shows:', + bullets: [ + ['Demand for EUR', 'from foreigners buying eurozone exports, assets, or travelling there, downward-sloping against the exchange rate.'], + ['Supply of EUR', 'from eurozone residents buying imports or investing abroad, upward-sloping.'], + ['Equilibrium exchange rate', 'where the curves cross, e.g. 1.10 USD/EUR.'], + ['Appreciation', 'demand shifts right (or supply left) to higher exchange rate.'], + ['Depreciation', 'demand shifts left (or supply right) to lower exchange rate.'], + ], + }, + howToDraw: [ + 'Prompt: "market for the British pound after an interest rate rise, showing appreciation", the AI labels axes as $ per £ automatically.', + 'Or start from the supply-and-demand template and double-click the axis labels to change them to "Exchange rate (USD/EUR)" and "Quantity of EUR".', + 'Shift the appropriate curve and mark both equilibria (e₁ to e₂) with dotted lines.', + 'Add an arrow annotation showing the appreciation/depreciation direction.', + 'For managed rates, add a horizontal intervention line and discuss reserves in your commentary.', + ], + iaTips: [ + 'Currency articles pair this diagram with the AD-AS model (a depreciation boosting net exports shifts AD right), keep both graphs in one project.', + 'Always state the exchange rate as a ratio in the axis label (USD per EUR), ambiguous labels are penalised.', + 'Central bank intervention articles: draw the rate the bank defends and the excess demand/supply it must absorb, similar to a price control.', + ], + faq: [ + ['Which curve shifts when interest rates rise?', 'Higher domestic interest rates attract foreign capital: demand for the currency shifts right (and supply may shift left as residents keep funds at home), an appreciation. Describe the scenario and the AI shifts the correct curve.'], + ['Can I draw a fixed exchange rate?', 'Yes, add a horizontal line at the pegged rate, like a price control, and mark the intervention gap.'], + ['Does this work for any currency pair?', 'Yes, all labels are editable, so any base/quote pair works.'], + ], + axes: ['Quantity of EUR', 'Exchange rate (USD/EUR)'], + diagram: { + lines: [ + [10, 90, 90, 10, '#ef4444', 'D_{EUR}'], + [10, 10, 90, 90, '#3b82f6', 'S_{EUR}'], + [25, 95, 90, 30, '#f97316', 'D_1', true], + ], + points: [[50, 50, 'e_1'], [60, 60, 'e_2']], + }, + related: ['ad-as-diagram', 'tariff-diagram', 'supply-and-demand'], + }, +]; diff --git a/scripts/update-supporters.mjs b/scripts/update-supporters.mjs new file mode 100644 index 0000000..598e2fa --- /dev/null +++ b/scripts/update-supporters.mjs @@ -0,0 +1,61 @@ +// Maintainer script: refresh the Supporters section of README.md from the +// database. Requires the Supabase secret key — run locally, then commit the diff: +// +// SUPABASE_URL=... SUPABASE_SECRET_KEY=... node scripts/update-supporters.mjs + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createClient } from '@supabase/supabase-js'; + +const url = process.env.SUPABASE_URL; +const key = process.env.SUPABASE_SECRET_KEY; +if (!url || !key) { + console.error('Set SUPABASE_URL and SUPABASE_SECRET_KEY.'); + process.exit(1); +} + +const README = join(dirname(fileURLToPath(import.meta.url)), '..', 'README.md'); +const START = ''; +const END = ''; + +const supabase = createClient(url, key, { auth: { persistSession: false } }); + +const { data, error } = await supabase + .from('profiles') + .select('supporter_name, pro_until, created_at') + .eq('show_in_supporters', true) + .not('supporter_name', 'is', null) + .gt('pro_until', new Date().toISOString()) + .order('created_at', { ascending: true }); + +if (error) { + console.error('Query failed:', error.message); + process.exit(1); +} + +const names = (data ?? []) + .map((row) => row.supporter_name?.trim()) + .filter((name) => name && name.length <= 50) + // Markdown-escape to keep the README safe from user-controlled input. + .map((name) => name.replace(/[\\`*_{}[\]()#+\-.!|<>]/g, (c) => `\\${c}`)); + +const block = names.length > 0 + ? names.map((n) => `**${n}**`).join(' · ') + : '*Become the first. See the [Supporter plan](https://ib-econgraph-ai.vercel.app/pricing).*'; + +const readme = readFileSync(README, 'utf8'); +const startIdx = readme.indexOf(START); +const endIdx = readme.indexOf(END); +if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { + console.error(`Markers ${START} / ${END} not found in README.md.`); + process.exit(1); +} + +const updated = + readme.slice(0, startIdx + START.length) + + '\n\n' + block + '\n\n' + + readme.slice(endIdx); + +writeFileSync(README, updated); +console.log(`Updated README with ${names.length} supporter(s).`); diff --git a/services/ai.ts b/services/ai.ts index 5c1c620..a18592d 100644 --- a/services/ai.ts +++ b/services/ai.ts @@ -1,15 +1,23 @@ import { getAIProvider } from './aiProvider'; import { generateDiagramData as generateDiagramDataGemini, hasApiKey as hasGeminiApiKey } from './gemini'; import { generateDiagramDataOpenRouter, hasOpenRouterApiKey } from './openrouter'; +import { generateDiagramDataHosted } from './hostedAi'; import { DiagramData } from '../types'; +/** + * Whether the current BYOK provider has a key configured. For the hosted + * provider this is always true — availability is decided by auth/entitlement + * state, which callers check via `useAuth()` (see aiIsReady in App). + */ export function hasApiKey(): boolean { const provider = getAIProvider(); + if (provider === 'hosted') return true; return provider === 'openrouter' ? hasOpenRouterApiKey() : hasGeminiApiKey(); } export async function generateDiagramData(prompt: string, history: string[] = []): Promise { const provider = getAIProvider(); + if (provider === 'hosted') return generateDiagramDataHosted(prompt, history); return provider === 'openrouter' ? generateDiagramDataOpenRouter(prompt, history) : generateDiagramDataGemini(prompt, history); diff --git a/services/aiProvider.ts b/services/aiProvider.ts index 770cbba..1251156 100644 --- a/services/aiProvider.ts +++ b/services/aiProvider.ts @@ -1,10 +1,11 @@ -export type AIProvider = 'gemini' | 'openrouter'; +export type AIProvider = 'gemini' | 'openrouter' | 'hosted'; const PROVIDER_STORAGE_KEY = 'econgraph_ai_provider'; export function getAIProvider(): AIProvider { const stored = localStorage.getItem(PROVIDER_STORAGE_KEY); - return stored === 'openrouter' ? 'openrouter' : 'gemini'; + if (stored === 'openrouter' || stored === 'hosted') return stored; + return 'gemini'; } export function setAIProvider(provider: AIProvider): void { @@ -12,5 +13,9 @@ export function setAIProvider(provider: AIProvider): void { } export function getAIProviderDisplayName(provider: AIProvider = getAIProvider()): string { - return provider === 'openrouter' ? 'OpenRouter' : 'Google AI Studio'; + switch (provider) { + case 'openrouter': return 'OpenRouter'; + case 'hosted': return 'EconGraph Cloud'; + default: return 'Google AI Studio'; + } } diff --git a/services/auth.tsx b/services/auth.tsx new file mode 100644 index 0000000..1a8e646 --- /dev/null +++ b/services/auth.tsx @@ -0,0 +1,237 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import type { Session, User } from '@supabase/supabase-js'; +import { supabase, isCloudConfigured } from './supabaseClient'; +import { clearTemplateCache } from './customTemplates'; +import { isProUntilActive } from './entitlement'; + +export interface Profile { + id: string; + email: string | null; + display_name: string | null; + supporter_name: string | null; + show_in_supporters: boolean; + pro_status: string; + pro_until: string | null; + plan_interval: string | null; +} + +export type EditableProfileFields = Partial>; + +interface AuthContextValue { + /** Whether Supabase is configured for this deployment at all. */ + configured: boolean; + /** True until the initial session restore has finished. */ + loading: boolean; + session: Session | null; + user: User | null; + profile: Profile | null; + /** Active Supporter (Pro) entitlement. */ + isPro: boolean; + /** + * True after the user follows a password-reset link (Supabase fires a + * PASSWORD_RECOVERY event). The Settings page uses this to prompt for a new + * password. + */ + recoveryMode: boolean; + /** Create an account with email + password. `needsConfirmation` when a + * verification email was sent and no session was established yet. */ + signUpWithPassword: (email: string, password: string) => Promise<{ error?: string; needsConfirmation?: boolean }>; + signInWithPassword: (email: string, password: string) => Promise<{ error?: string }>; + /** Send a password-reset email. */ + resetPassword: (email: string) => Promise<{ error?: string }>; + /** Set a new password for the signed-in (or recovering) user. */ + updatePassword: (password: string) => Promise<{ error?: string }>; + clearRecoveryMode: () => void; + signInWithGoogle: () => Promise<{ error?: string }>; + signOut: () => Promise; + refreshProfile: () => Promise; + updateProfile: (patch: EditableProfileFields) => Promise<{ error?: string }>; +} + +const NOT_CONFIGURED = { error: 'Accounts are not available on this deployment.' } as const; + +const AuthContext = createContext({ + configured: false, + loading: false, + session: null, + user: null, + profile: null, + isPro: false, + recoveryMode: false, + signUpWithPassword: async () => NOT_CONFIGURED, + signInWithPassword: async () => NOT_CONFIGURED, + resetPassword: async () => NOT_CONFIGURED, + updatePassword: async () => NOT_CONFIGURED, + clearRecoveryMode: () => { }, + signInWithGoogle: async () => NOT_CONFIGURED, + signOut: async () => { }, + refreshProfile: async () => { }, + updateProfile: async () => NOT_CONFIGURED, +}); + +export function profileIsPro(profile: Profile | null): boolean { + return isProUntilActive(profile?.pro_until); +} + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [session, setSession] = useState(null); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(isCloudConfigured); + const [recoveryMode, setRecoveryMode] = useState(false); + const userIdRef = useRef(null); + + const fetchProfile = useCallback(async (userId: string | null) => { + if (!supabase || !userId) { + setProfile(null); + return; + } + const { data, error } = await supabase + .from('profiles') + .select('id, email, display_name, supporter_name, show_in_supporters, pro_status, pro_until, plan_interval') + .eq('id', userId) + .maybeSingle(); + if (!error && userIdRef.current === userId) { + setProfile((data as Profile) ?? null); + } + }, []); + + useEffect(() => { + if (!supabase) return; + + let cancelled = false; + supabase.auth.getSession().then(({ data }) => { + if (cancelled) return; + setSession(data.session); + userIdRef.current = data.session?.user?.id ?? null; + fetchProfile(userIdRef.current).finally(() => { + if (!cancelled) setLoading(false); + }); + }).catch((err) => { + // Don't leave the UI stuck on the loading spinner if session + // restore fails (transient network/storage error). + console.error('auth: getSession failed', err); + if (!cancelled) setLoading(false); + }); + + const { data: sub } = supabase.auth.onAuthStateChange((event, newSession) => { + // Arrived via a password-reset link → prompt for a new password. + if (event === 'PASSWORD_RECOVERY') setRecoveryMode(true); + setSession(newSession); + const newUserId = newSession?.user?.id ?? null; + if (newUserId !== userIdRef.current) { + if (!newUserId) clearTemplateCache(); // signed out / expired elsewhere + userIdRef.current = newUserId; + fetchProfile(newUserId); + } + }); + + return () => { + cancelled = true; + sub.subscription.unsubscribe(); + }; + }, [fetchProfile]); + + const signUpWithPassword = useCallback(async (email: string, password: string) => { + if (!supabase) return NOT_CONFIGURED; + const { data, error } = await supabase.auth.signUp({ + email: email.trim(), + password, + options: { emailRedirectTo: `${window.location.origin}/settings` }, + }); + if (error) return { error: error.message }; + // Session present → email confirmation is disabled, user is signed in. + if (data.session) return {}; + // Supabase returns a user with an empty `identities` array when the + // email is already registered (it avoids leaking that fact via an error). + if (data.user && Array.isArray(data.user.identities) && data.user.identities.length === 0) { + return { error: 'An account with this email already exists. Try signing in instead.' }; + } + return { needsConfirmation: true }; + }, []); + + const signInWithPassword = useCallback(async (email: string, password: string) => { + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.signInWithPassword({ email: email.trim(), password }); + if (!error) return {}; + // Friendlier copy for the common "not confirmed yet" case. + if (/email not confirmed/i.test(error.message)) { + return { error: 'Please confirm your email first, check your inbox for the verification link.' }; + } + return { error: error.message }; + }, []); + + const resetPassword = useCallback(async (email: string) => { + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { + redirectTo: `${window.location.origin}/settings`, + }); + return error ? { error: error.message } : {}; + }, []); + + const updatePassword = useCallback(async (password: string) => { + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.updateUser({ password }); + if (error) return { error: error.message }; + setRecoveryMode(false); + return {}; + }, []); + + const clearRecoveryMode = useCallback(() => setRecoveryMode(false), []); + + const signInWithGoogle = useCallback(async () => { + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: `${window.location.origin}/settings` }, + }); + return error ? { error: error.message } : {}; + }, []); + + const signOut = useCallback(async () => { + if (!supabase) return; + clearTemplateCache(); + setRecoveryMode(false); + await supabase.auth.signOut(); + setProfile(null); + }, []); + + const refreshProfile = useCallback(async () => { + await fetchProfile(userIdRef.current); + }, [fetchProfile]); + + const updateProfile = useCallback(async (patch: EditableProfileFields) => { + if (!supabase || !userIdRef.current) return { error: 'Not signed in.' }; + const { error } = await supabase + .from('profiles') + .update(patch) + .eq('id', userIdRef.current); + if (error) return { error: error.message }; + await fetchProfile(userIdRef.current); + return {}; + }, [fetchProfile]); + + const value = useMemo(() => ({ + configured: isCloudConfigured, + loading, + session, + user: session?.user ?? null, + profile, + isPro: profileIsPro(profile), + recoveryMode, + signUpWithPassword, + signInWithPassword, + resetPassword, + updatePassword, + clearRecoveryMode, + signInWithGoogle, + signOut, + refreshProfile, + updateProfile, + }), [loading, session, profile, recoveryMode, signUpWithPassword, signInWithPassword, resetPassword, updatePassword, clearRecoveryMode, signInWithGoogle, signOut, refreshProfile, updateProfile]); + + return {children}; +}; + +export function useAuth(): AuthContextValue { + return useContext(AuthContext); +} diff --git a/services/billing.ts b/services/billing.ts new file mode 100644 index 0000000..64ae79b --- /dev/null +++ b/services/billing.ts @@ -0,0 +1,56 @@ +import { getAccessToken } from './supabaseClient'; + +async function callBillingEndpoint(path: string, body?: unknown): Promise<{ url?: string; error?: string }> { + const token = await getAccessToken(); + if (!token) return { error: 'Please sign in first.' }; + + try { + const res = await fetch(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await res.json().catch(() => null) as { url?: string; error?: string } | null; + if (!res.ok || !data?.url) { + return { error: data?.error || 'Something went wrong. Please try again.' }; + } + return { url: data.url }; + } catch { + return { error: 'Could not reach the server. Check your connection and try again.' }; + } +} + +/** Start a Polar checkout for the Supporter plan. Returns the checkout URL. */ +export function startCheckout(interval: 'month' | 'year') { + return callBillingEndpoint('/api/checkout', { interval }); +} + +/** Open the Polar customer portal (manage / cancel subscription, invoices). */ +export function openBillingPortal() { + return callBillingEndpoint('/api/portal'); +} + +/** + * Permanently delete the signed-in user's account and all cloud data (cancels + * any active subscription first). Returns {} on success, or { error }. + */ +export async function deleteAccount(): Promise<{ error?: string }> { + const token = await getAccessToken(); + if (!token) return { error: 'Please sign in first.' }; + try { + const res = await fetch('/api/delete-account', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json().catch(() => null) as { deleted?: boolean; error?: string } | null; + if (!res.ok || !data?.deleted) { + return { error: data?.error || 'Could not delete your account. Please try again.' }; + } + return {}; + } catch { + return { error: 'Could not reach the server. Check your connection and try again.' }; + } +} diff --git a/services/cloudErrors.ts b/services/cloudErrors.ts new file mode 100644 index 0000000..a30c76e --- /dev/null +++ b/services/cloudErrors.ts @@ -0,0 +1,9 @@ +/** + * A row-level-security denial is how Supabase reports a write blocked by a + * Supporter-gated RLS policy. Detecting it lets each cloud feature show a + * friendly "this is part of the Supporter plan" message instead of a raw + * Postgres error. Shared so the (fragile) detection string lives in one place. + */ +export function isRlsDenied(message: string): boolean { + return /row-level security/i.test(message); +} diff --git a/services/customTemplates.ts b/services/customTemplates.ts new file mode 100644 index 0000000..b804019 --- /dev/null +++ b/services/customTemplates.ts @@ -0,0 +1,149 @@ +import { supabase } from './supabaseClient'; +import { DiagramData } from '../types'; +import { isRlsDenied } from './cloudErrors'; + +export interface CustomTemplate { + id: string; + name: string; + description: string; + data: Partial; + createdAt: number; +} + +const CACHE_KEY = 'econgraph_custom_templates_v1'; + +// The cache is tagged with its owning user so it can never be shown to a +// different (or signed-out) account on a shared browser. +interface TemplateCache { + userId: string; + templates: CustomTemplate[]; +} + +function readCache(userId: string): CustomTemplate[] { + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as TemplateCache; + if (parsed?.userId !== userId || !Array.isArray(parsed.templates)) return []; + return parsed.templates; + } catch { + return []; + } +} + +function writeCache(userId: string, templates: CustomTemplate[]): void { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify({ userId, templates } satisfies TemplateCache)); + } catch { /* quota — cache is best-effort */ } +} + +/** Clear the local template cache (call on sign-out). */ +export function clearTemplateCache(): void { + try { + localStorage.removeItem(CACHE_KEY); + } catch { /* ignore */ } +} + +/** Instant, offline-friendly read of the local cache for a specific user. */ +export function listCachedTemplates(userId: string): CustomTemplate[] { + return readCache(userId); +} + +/** Pull the authoritative list from the cloud and refresh the cache. */ +export async function fetchCustomTemplates(userId: string): Promise { + if (!supabase) return readCache(userId); + const { data, error } = await supabase + .from('templates') + .select('id, name, description, data, last_modified') + .order('last_modified', { ascending: false }); + if (error) return readCache(userId); + const templates: CustomTemplate[] = (data ?? []).map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + data: row.data as Partial, + createdAt: row.last_modified, + })); + writeCache(userId, templates); + return templates; +} + +/** Extract reusable content from the current diagram. */ +export function templateDataFromDiagram(diagram: DiagramData): Partial { + return { + curves: diagram.curves, + shadedRegions: diagram.shadedRegions, + annotatedPoints: diagram.annotatedPoints, + textLabels: diagram.textLabels ?? [], + }; +} + +export async function saveCustomTemplate( + userId: string, + input: { name: string; description?: string; data: Partial }, +): Promise<{ template?: CustomTemplate; error?: string }> { + if (!supabase) return { error: 'Custom templates are not available on this deployment.' }; + const template: CustomTemplate = { + id: crypto.randomUUID(), + name: input.name.trim(), + description: input.description?.trim() ?? '', + data: input.data, + createdAt: Date.now(), + }; + if (!template.name) return { error: 'Please give the template a name.' }; + + const { error } = await supabase.from('templates').insert({ + id: template.id, + user_id: userId, + name: template.name, + description: template.description, + category: 'custom', + data: template.data, + last_modified: template.createdAt, + }); + if (error) { + if (isRlsDenied(error.message)) { + return { error: 'Custom templates are part of the Supporter plan.' }; + } + return { error: error.message }; + } + writeCache(userId, [template, ...readCache(userId)]); + return { template }; +} + +export async function deleteCustomTemplate(userId: string, id: string): Promise<{ error?: string }> { + if (!supabase) return { error: 'Custom templates are not available on this deployment.' }; + const { error } = await supabase.from('templates').delete().eq('id', id); + if (error) return { error: error.message }; + writeCache(userId, readCache(userId).filter((t) => t.id !== id)); + return {}; +} + +export interface CloudVersion { + id: string; + graphId: string; + title: string; + data: unknown; + lastModified: number; + createdAt: string; +} + +/** Version history for a graph (Supporter feature; newest first). */ +export async function fetchGraphVersions(graphId: string): Promise { + if (!supabase) return []; + const { data, error } = await supabase + .from('graph_versions') + .select('id, graph_id, title, data, last_modified, created_at') + .eq('graph_id', graphId) + .order('created_at', { ascending: false }) + .limit(30); + if (error) return []; + return (data ?? []).map((row) => ({ + id: row.id, + graphId: row.graph_id, + title: row.title, + data: row.data, + lastModified: row.last_modified, + createdAt: row.created_at, + })); +} diff --git a/services/diagramPrompt.ts b/services/diagramPrompt.ts new file mode 100644 index 0000000..624745b --- /dev/null +++ b/services/diagramPrompt.ts @@ -0,0 +1,125 @@ +import { Type, Schema } from "@google/genai"; + +// Shared between the browser (BYOK Gemini provider) and the serverless hosted +// AI endpoint (api/generate.ts). Keep this module free of browser-only APIs. + +export const DIAGRAM_SYSTEM_INSTRUCTION = ` + You are an expert Economics Professor and SVG Graph Generator. + Your goal is to generate precise coordinate data for economic diagrams based on user prompts. + + Rules for generation: + 1. Coordinate System: Use a logical scale (e.g., 0-10 or 0-100). Keep it consistent. + 2. Accuracy: Calculate intersection points mathematically. If Supply is P = 10 + Q and Demand is P = 100 - Q, Equilibrium is Q=45, P=55. + 3. Shared Coordinates (CRITICAL): + - If an equilibrium point E is at (50, 50), ensuring the Supply Curve, Demand Curve, and any Shaded Regions ALL use the exact coordinate (50, 50). + - Do not approximate. If a shaded region (e.g., Consumer Surplus) is bounded by the Price axis, Demand curve, and Equilibrium price, the vertices must strictly match the curve points. + 4. Shading: + - Provide a closed polygon for shaded areas. + 5. Labels: + - Use LaTeX-style formatting for subscripts and superscripts. + - Example: "P_1", "Q^*", "Q_{tax}", "D_{private}". + 6. Context: + - If the user asks for "Monopoly", ensure MR is below D. + - If the user asks for "Tax", shift the appropriate curve. + + Output purely the JSON object matching the schema. + `; + +export const GEMINI_DIAGRAM_SCHEMA: Schema = { + type: Type.OBJECT, + properties: { + title: { type: Type.STRING, description: "Title of the economic diagram" }, + summary: { type: Type.STRING, description: "Brief explanation of what the diagram shows" }, + xAxis: { + type: Type.OBJECT, + properties: { + label: { type: Type.STRING, description: "Label for X axis (e.g. Quantity)" }, + min: { type: Type.NUMBER, description: "Always 0 usually" }, + max: { type: Type.NUMBER, description: "Scale maximum, usually 10 or 100" } + }, + required: ["label", "min", "max"] + }, + yAxis: { + type: Type.OBJECT, + properties: { + label: { type: Type.STRING, description: "Label for Y axis (e.g. Price)" }, + min: { type: Type.NUMBER }, + max: { type: Type.NUMBER } + }, + required: ["label", "min", "max"] + }, + curves: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + id: { type: Type.STRING }, + label: { type: Type.STRING, description: "Label like D, S, MC, ATC. Use _ for subscript (D_1) and ^ for superscript." }, + color: { type: Type.STRING, description: "Hex code. Use standard colors: Red #ef4444 for Demand/Marginal Benefit, Blue #3b82f6 for Supply/MC, etc." }, + type: { type: Type.STRING, enum: ["linear", "bezier", "vertical", "horizontal"] }, + width: { type: Type.NUMBER, description: "Stroke width, default 2" }, + strokeDasharray: { type: Type.STRING, description: "Optional, e.g. '5,5' for dashed" }, + points: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + x: { type: Type.NUMBER }, + y: { type: Type.NUMBER } + }, + required: ["x", "y"] + }, + description: "2 points for linear, 3 points for bezier (start, control, end)" + } + }, + required: ["id", "label", "color", "type", "points", "width"] + } + }, + annotatedPoints: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + x: { type: Type.NUMBER }, + y: { type: Type.NUMBER }, + label: { type: Type.STRING, description: "e.g. E_1, P^*, Q_0. Use _ for subscript and ^ for superscript." }, + labelPosition: { type: Type.STRING, enum: ["top", "bottom", "left", "right", "top-right", "top-left", "bottom-right", "bottom-left"] }, + showDottedLines: { type: Type.BOOLEAN, description: "If true, draws dotted lines to both axes" }, + color: { type: Type.STRING } + }, + required: ["x", "y", "label", "showDottedLines"] + } + }, + shadedRegions: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + id: { type: Type.STRING }, + label: { type: Type.STRING, description: "Label for the area (e.g. DWL, CS, PS)" }, + color: { type: Type.STRING, description: "RGBA color string, e.g., 'rgba(239, 68, 68, 0.2)'" }, + points: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + x: { type: Type.NUMBER }, + y: { type: Type.NUMBER } + }, + required: ["x", "y"] + }, + description: "Ordered vertices of the polygon to fill." + } + }, + required: ["id", "label", "color", "points"] + } + } + }, + required: ["title", "xAxis", "yAxis", "curves", "annotatedPoints", "shadedRegions", "summary"] +}; + +export function buildHistoryContext(history: string[]): string { + return history.length > 0 + ? `Previous context:\n${history.join("\n")}\n\nCurrent Request:` + : "Request:"; +} diff --git a/services/entitlement.ts b/services/entitlement.ts new file mode 100644 index 0000000..685a1ce --- /dev/null +++ b/services/entitlement.ts @@ -0,0 +1,13 @@ +/** + * Single source of truth for the "active Supporter" entitlement rule, shared by + * the client (services/auth.tsx) and the serverless API (api/_lib/supabaseAdmin). + * A profile is entitled when its paid-through timestamp is set and still in the + * future. Pure (no imports) so it's safe to use in both runtimes. + * + * NOTE: the Postgres `is_pro()` function in supabase/schema.sql enforces the same + * rule inside RLS policies — keep the two in sync if this ever changes. + */ +export function isProUntilActive(proUntil: string | null | undefined): boolean { + if (!proUntil) return false; + return Date.parse(proUntil) > Date.now(); +} diff --git a/services/gemini.ts b/services/gemini.ts index e7020d4..fbe0881 100644 --- a/services/gemini.ts +++ b/services/gemini.ts @@ -1,37 +1,24 @@ -import { GoogleGenAI, Type, Schema } from "@google/genai"; +import { GoogleGenAI } from "@google/genai"; import { DiagramData } from "../types"; +import { DIAGRAM_SYSTEM_INSTRUCTION, GEMINI_DIAGRAM_SCHEMA, buildHistoryContext } from "./diagramPrompt"; +import { obfuscateKey, deobfuscateKey } from "./keyObfuscation"; const STORAGE_KEY = 'econgraph_api_key'; const MODEL_STORAGE_KEY = 'econgraph_selected_model'; -// Simple obfuscation to avoid plain-text keys in localStorage. -// This is NOT encryption — true encryption is impossible when the -// decryption key must also live client-side. The purpose is to -// prevent casual exposure (e.g. shoulder-surfing DevTools). -const OBFUSCATION_PREFIX = 'egk_'; - -function obfuscate(key: string): string { - return OBFUSCATION_PREFIX + btoa(key); -} - -function deobfuscate(stored: string): string { - if (!stored.startsWith(OBFUSCATION_PREFIX)) return stored; - return atob(stored.slice(OBFUSCATION_PREFIX.length)); -} - export function saveApiKey(key: string): void { if (!key.trim()) { localStorage.removeItem(STORAGE_KEY); return; } - localStorage.setItem(STORAGE_KEY, obfuscate(key.trim())); + localStorage.setItem(STORAGE_KEY, obfuscateKey(key.trim())); } export function getApiKey(): string { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return ''; try { - return deobfuscate(stored); + return deobfuscateKey(stored); } catch { return ''; } @@ -100,99 +87,6 @@ export async function fetchAvailableModels(): Promise { } } -const diagramSchema: Schema = { - type: Type.OBJECT, - properties: { - title: { type: Type.STRING, description: "Title of the economic diagram" }, - summary: { type: Type.STRING, description: "Brief explanation of what the diagram shows" }, - xAxis: { - type: Type.OBJECT, - properties: { - label: { type: Type.STRING, description: "Label for X axis (e.g. Quantity)" }, - min: { type: Type.NUMBER, description: "Always 0 usually" }, - max: { type: Type.NUMBER, description: "Scale maximum, usually 10 or 100" } - }, - required: ["label", "min", "max"] - }, - yAxis: { - type: Type.OBJECT, - properties: { - label: { type: Type.STRING, description: "Label for Y axis (e.g. Price)" }, - min: { type: Type.NUMBER }, - max: { type: Type.NUMBER } - }, - required: ["label", "min", "max"] - }, - curves: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - id: { type: Type.STRING }, - label: { type: Type.STRING, description: "Label like D, S, MC, ATC. Use _ for subscript (D_1) and ^ for superscript." }, - color: { type: Type.STRING, description: "Hex code. Use standard colors: Red #ef4444 for Demand/Marginal Benefit, Blue #3b82f6 for Supply/MC, etc." }, - type: { type: Type.STRING, enum: ["linear", "bezier", "vertical", "horizontal"] }, - width: { type: Type.NUMBER, description: "Stroke width, default 2" }, - strokeDasharray: { type: Type.STRING, description: "Optional, e.g. '5,5' for dashed" }, - points: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - x: { type: Type.NUMBER }, - y: { type: Type.NUMBER } - }, - required: ["x", "y"] - }, - description: "2 points for linear, 3 points for bezier (start, control, end)" - } - }, - required: ["id", "label", "color", "type", "points", "width"] - } - }, - annotatedPoints: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - x: { type: Type.NUMBER }, - y: { type: Type.NUMBER }, - label: { type: Type.STRING, description: "e.g. E_1, P^*, Q_0. Use _ for subscript and ^ for superscript." }, - labelPosition: { type: Type.STRING, enum: ["top", "bottom", "left", "right", "top-right", "top-left", "bottom-right", "bottom-left"] }, - showDottedLines: { type: Type.BOOLEAN, description: "If true, draws dotted lines to both axes" }, - color: { type: Type.STRING } - }, - required: ["x", "y", "label", "showDottedLines"] - } - }, - shadedRegions: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - id: { type: Type.STRING }, - label: { type: Type.STRING, description: "Label for the area (e.g. DWL, CS, PS)" }, - color: { type: Type.STRING, description: "RGBA color string, e.g., 'rgba(239, 68, 68, 0.2)'" }, - points: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - x: { type: Type.NUMBER }, - y: { type: Type.NUMBER } - }, - required: ["x", "y"] - }, - description: "Ordered vertices of the polygon to fill." - } - }, - required: ["id", "label", "color", "points"] - } - } - }, - required: ["title", "xAxis", "yAxis", "curves", "annotatedPoints", "shadedRegions", "summary"] -}; - export async function generateDiagramData(prompt: string, history: string[] = []): Promise { const apiKey = getApiKey(); if (!apiKey) { @@ -202,41 +96,14 @@ export async function generateDiagramData(prompt: string, history: string[] = [] const ai = new GoogleGenAI({ apiKey }); const model = getSelectedModel(); - // Convert history to a text context block - const historyContext = history.length > 0 - ? `Previous context:\n${history.join("\n")}\n\nCurrent Request:` - : "Request:"; - - const systemInstruction = ` - You are an expert Economics Professor and SVG Graph Generator. - Your goal is to generate precise coordinate data for economic diagrams based on user prompts. - - Rules for generation: - 1. Coordinate System: Use a logical scale (e.g., 0-10 or 0-100). Keep it consistent. - 2. Accuracy: Calculate intersection points mathematically. If Supply is P = 10 + Q and Demand is P = 100 - Q, Equilibrium is Q=45, P=55. - 3. Shared Coordinates (CRITICAL): - - If an equilibrium point E is at (50, 50), ensuring the Supply Curve, Demand Curve, and any Shaded Regions ALL use the exact coordinate (50, 50). - - Do not approximate. If a shaded region (e.g., Consumer Surplus) is bounded by the Price axis, Demand curve, and Equilibrium price, the vertices must strictly match the curve points. - 4. Shading: - - Provide a closed polygon for shaded areas. - 5. Labels: - - Use LaTeX-style formatting for subscripts and superscripts. - - Example: "P_1", "Q^*", "Q_{tax}", "D_{private}". - 6. Context: - - If the user asks for "Monopoly", ensure MR is below D. - - If the user asks for "Tax", shift the appropriate curve. - - Output purely the JSON object matching the schema. - `; - try { const response = await ai.models.generateContent({ model, - contents: `${historyContext} ${prompt}`, + contents: `${buildHistoryContext(history)} ${prompt}`, config: { - systemInstruction, + systemInstruction: DIAGRAM_SYSTEM_INSTRUCTION, responseMimeType: "application/json", - responseSchema: diagramSchema, + responseSchema: GEMINI_DIAGRAM_SCHEMA, temperature: 0.2, // Lower temperature for better math consistency } }); diff --git a/services/hostedAi.ts b/services/hostedAi.ts new file mode 100644 index 0000000..6ac0fda --- /dev/null +++ b/services/hostedAi.ts @@ -0,0 +1,60 @@ +import { DiagramData } from '../types'; +import { getAccessToken } from './supabaseClient'; + +export interface HostedUsage { + used: number; + limit: number; + month: string; + isPro: boolean; +} + +/** + * Generate a diagram through the hosted (server-side) AI endpoint. + * Requires a signed-in Supporter, the server enforces both. + */ +export async function generateDiagramDataHosted(prompt: string, history: string[] = []): Promise { + const token = await getAccessToken(); + if (!token) { + throw new Error('Please sign in (Settings > Account) to use hosted AI, or switch to your own API key.'); + } + + let res: Response; + try { + res = await fetch('/api/generate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ prompt, history }), + }); + } catch { + throw new Error('Could not reach the server. Check your connection and try again.'); + } + + const body = await res.json().catch(() => null) as + | { diagram?: DiagramData; error?: string } + | null; + + // Guard the shape too: an empty/degenerate diagram ({} with no axes) would + // crash the renderer, so treat it as a failure rather than pass it through. + if (!res.ok || !body?.diagram || !body.diagram.xAxis || !body.diagram.yAxis) { + throw new Error(body?.error || 'Hosted AI generation failed. Please try again.'); + } + return body.diagram; +} + +/** Fetch the signed-in user's hosted AI usage. Returns null when unavailable. */ +export async function fetchHostedUsage(): Promise { + const token = await getAccessToken(); + if (!token) return null; + try { + const res = await fetch('/api/usage', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; + return await res.json() as HostedUsage; + } catch { + return null; + } +} diff --git a/services/keyObfuscation.ts b/services/keyObfuscation.ts new file mode 100644 index 0000000..471273d --- /dev/null +++ b/services/keyObfuscation.ts @@ -0,0 +1,14 @@ +// Simple obfuscation to avoid plain-text API keys sitting in localStorage. +// This is NOT encryption — true encryption is impossible when the decryption +// key must also live client-side. The purpose is only to prevent casual +// exposure (e.g. shoulder-surfing DevTools). Shared by every BYO-key provider. +const OBFUSCATION_PREFIX = 'egk_'; + +export function obfuscateKey(key: string): string { + return OBFUSCATION_PREFIX + btoa(key); +} + +export function deobfuscateKey(stored: string): string { + if (!stored.startsWith(OBFUSCATION_PREFIX)) return stored; + return atob(stored.slice(OBFUSCATION_PREFIX.length)); +} diff --git a/services/openrouter.ts b/services/openrouter.ts index 5b68dd1..0f87514 100644 --- a/services/openrouter.ts +++ b/services/openrouter.ts @@ -1,32 +1,22 @@ import { DiagramData } from '../types'; +import { obfuscateKey, deobfuscateKey } from './keyObfuscation'; const STORAGE_KEY = 'econgraph_openrouter_api_key'; const MODEL_STORAGE_KEY = 'econgraph_openrouter_selected_model'; -const OBFUSCATION_PREFIX = 'egk_'; - -function obfuscate(key: string): string { - return OBFUSCATION_PREFIX + btoa(key); -} - -function deobfuscate(stored: string): string { - if (!stored.startsWith(OBFUSCATION_PREFIX)) return stored; - return atob(stored.slice(OBFUSCATION_PREFIX.length)); -} - export function saveOpenRouterApiKey(key: string): void { if (!key.trim()) { localStorage.removeItem(STORAGE_KEY); return; } - localStorage.setItem(STORAGE_KEY, obfuscate(key.trim())); + localStorage.setItem(STORAGE_KEY, obfuscateKey(key.trim())); } export function getOpenRouterApiKey(): string { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return ''; try { - return deobfuscate(stored); + return deobfuscateKey(stored); } catch { return ''; } diff --git a/services/shares.ts b/services/shares.ts new file mode 100644 index 0000000..582190e --- /dev/null +++ b/services/shares.ts @@ -0,0 +1,150 @@ +import { supabase } from './supabaseClient'; +import { DiagramData, Graph, Project } from '../types'; +import { isRlsDenied } from './cloudErrors'; + +export interface SharedGraphEntry { + id: string; + title: string; + caption?: string; + diagramData: DiagramData; +} + +export interface GraphSharePayload { + kind: 'graph'; + title: string; + caption?: string; + diagramData: DiagramData; +} + +export interface ProjectSharePayload { + kind: 'project'; + name: string; + graphs: SharedGraphEntry[]; +} + +export type SharePayload = GraphSharePayload | ProjectSharePayload; + +export function shareUrl(shareId: string): string { + return `${window.location.origin}/s/${shareId}`; +} + +/** 24 hex chars (96 bits) — unguessable slug. */ +export function newShareSlug(): string { + const bytes = new Uint8Array(12); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** Shares never include chat history — diagram content only. */ +export function graphSharePayload(graph: Graph): GraphSharePayload { + return { + kind: 'graph', + title: graph.diagramData.title || graph.title, + caption: graph.caption || graph.diagramData.caption, + diagramData: graph.diagramData, + }; +} + +export function projectSharePayload(project: Project, graphs: Graph[]): ProjectSharePayload { + return { + kind: 'project', + name: project.name, + graphs: graphs + .filter((g) => g.projectId === project.id) + .map((g) => ({ + id: g.id, + title: g.diagramData.title || g.title, + caption: g.caption || g.diagramData.caption, + diagramData: g.diagramData, + })), + }; +} + +export async function getShareIdForGraph(graphId: string): Promise { + if (!supabase) return null; + const { data } = await supabase + .from('shares') + .select('id') + .eq('kind', 'graph') + .eq('graph_id', graphId) + .limit(1) + .maybeSingle(); + return data?.id ?? null; +} + +export async function getShareIdForProject(projectId: string): Promise { + if (!supabase) return null; + const { data } = await supabase + .from('shares') + .select('id') + .eq('kind', 'project') + .eq('project_id', projectId) + .limit(1) + .maybeSingle(); + return data?.id ?? null; +} + +export async function createOrUpdateGraphShare(userId: string, graph: Graph): Promise<{ id?: string; error?: string }> { + if (!supabase) return { error: 'Sharing is not available on this deployment.' }; + const existing = await getShareIdForGraph(graph.id); + const id = existing ?? newShareSlug(); + const { error } = await supabase.from('shares').upsert({ + id, + user_id: userId, + kind: 'graph', + graph_id: graph.id, + project_id: null, + payload: graphSharePayload(graph), + updated_at: new Date().toISOString(), + }); + if (error) return { error: friendlyShareError(error.message) }; + return { id }; +} + +export async function createOrUpdateProjectShare( + userId: string, + project: Project, + graphs: Graph[], +): Promise<{ id?: string; error?: string }> { + if (!supabase) return { error: 'Sharing is not available on this deployment.' }; + const existing = await getShareIdForProject(project.id); + const id = existing ?? newShareSlug(); + const { error } = await supabase.from('shares').upsert({ + id, + user_id: userId, + kind: 'project', + graph_id: null, + project_id: project.id, + payload: projectSharePayload(project, graphs), + updated_at: new Date().toISOString(), + }); + if (error) return { error: friendlyShareError(error.message) }; + return { id }; +} + +export async function revokeShare(shareId: string): Promise<{ error?: string }> { + if (!supabase) return { error: 'Sharing is not available on this deployment.' }; + const { error } = await supabase.from('shares').delete().eq('id', shareId); + return error ? { error: error.message } : {}; +} + +/** + * Public fetch — works without a session (anyone with the link). Reads through + * the get_share() RPC so the shares table stays non-enumerable by anon. + * Throws on transport/database errors so callers can distinguish "not found" + * (null) from "couldn't load" (throw). + */ +export async function fetchSharedPayload(slug: string): Promise { + if (!supabase) return null; + const { data, error } = await supabase.rpc('get_share', { p_id: slug }); + if (error) throw new Error(error.message); + if (!data) return null; + return data as SharePayload; +} + +function friendlyShareError(message: string): string { + if (isRlsDenied(message)) { + return 'Sharing links are part of the Supporter plan.'; + } + return message; +} diff --git a/services/supabaseClient.ts b/services/supabaseClient.ts new file mode 100644 index 0000000..65f2fe9 --- /dev/null +++ b/services/supabaseClient.ts @@ -0,0 +1,28 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; + +// The app is fully functional without Supabase — accounts, sync, sharing and +// hosted AI simply stay hidden. This keeps self-hosted/forked deployments +// zero-config. +const url = import.meta.env.VITE_SUPABASE_URL; +// Supabase publishable key (`sb_publishable_…`), the modern replacement for the +// legacy anon key. Low-privilege and safe to ship in the client bundle. +const publishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; + +export const supabase: SupabaseClient | null = + url && publishableKey + ? createClient(url, publishableKey, { + auth: { + persistSession: true, + autoRefreshToken: true, + detectSessionInUrl: true, + }, + }) + : null; + +export const isCloudConfigured = supabase !== null; + +export async function getAccessToken(): Promise { + if (!supabase) return null; + const { data } = await supabase.auth.getSession(); + return data.session?.access_token ?? null; +} diff --git a/services/sync.ts b/services/sync.ts new file mode 100644 index 0000000..0e6d3b7 --- /dev/null +++ b/services/sync.ts @@ -0,0 +1,539 @@ +import { supabase } from './supabaseClient'; +import { graphSharePayload, projectSharePayload } from './shares'; +import { Graph, Project } from '../types'; +import { isRlsDenied } from './cloudErrors'; + +// ───────────────────────────────────────────────────────────────────────────── +// Local-first cloud sync (Supporter feature). +// +// localStorage remains the working store; this module reconciles it with +// Supabase using last-write-wins on the client's `lastModified` timestamps. +// Deletions are tracked with tombstones on both sides so a delete on one +// device doesn't get resurrected by a stale copy on another. +// ───────────────────────────────────────────────────────────────────────────── + +const TOMBSTONE_KEY = 'econgraph_tombstones_v1'; +const TOMBSTONE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; +const VERSIONS_TO_KEEP = 30; + +// Content hash of each graph's last version snapshot, so we don't write a fresh +// full snapshot when only last_modified changed (rename, re-parenting, re-import, +// idempotent autosave). graph_versions is the fastest-growing table on the free +// tier, and these duplicates are pure waste. Per-device/best-effort: a cleared +// store just means one extra snapshot. +const VERSION_HASH_KEY = 'econgraph_version_hashes_v1'; + +function loadVersionHashes(): Record { + try { + const raw = localStorage.getItem(VERSION_HASH_KEY); + if (raw) return JSON.parse(raw) as Record; + } catch { /* corrupted — start fresh */ } + return {}; +} + +function saveVersionHashes(map: Record): void { + try { + localStorage.setItem(VERSION_HASH_KEY, JSON.stringify(map)); + } catch { /* quota — best-effort */ } +} + +/** Small, fast, non-cryptographic content hash (djb2). Collisions only cost a + * skipped snapshot, so a cheap hash is fine here. */ +function contentHash(s: string): string { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h.toString(36); +} + +interface TombstoneStore { + graphs: Record; + projects: Record; +} + +function loadTombstones(): TombstoneStore { + try { + const raw = localStorage.getItem(TOMBSTONE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return { + graphs: parsed.graphs ?? {}, + projects: parsed.projects ?? {}, + }; + } + } catch { /* corrupted store — start fresh */ } + return { graphs: {}, projects: {} }; +} + +function saveTombstones(store: TombstoneStore): void { + const cutoff = Date.now() - TOMBSTONE_MAX_AGE_MS; + for (const kind of ['graphs', 'projects'] as const) { + for (const [id, ts] of Object.entries(store[kind])) { + if (ts < cutoff) delete store[kind][id]; + } + } + try { + localStorage.setItem(TOMBSTONE_KEY, JSON.stringify(store)); + } catch { /* quota — tombstones are best-effort */ } +} + +/** Call whenever graphs/projects are deleted locally so sync can propagate it. */ +export function recordTombstones(kind: 'graphs' | 'projects', ids: string[]): void { + if (ids.length === 0) return; + const store = loadTombstones(); + const now = Date.now(); + for (const id of ids) store[kind][id] = now; + saveTombstones(store); +} + +/** + * Remove tombstones for the given ids (e.g. when a backup import restores them), + * so a live row and a tombstone for the same id are never queued together. + */ +export function clearTombstones(kind: 'graphs' | 'projects', ids: string[]): void { + if (ids.length === 0) return; + const store = loadTombstones(); + let changed = false; + for (const id of ids) { + if (store[kind][id] !== undefined) { + delete store[kind][id]; + changed = true; + } + } + if (changed) saveTombstones(store); +} + +/** + * Fetch the ids of the signed-in user's live (non-deleted) cloud graphs and + * projects. Backup restore uses this so "replace everything" can also tombstone + * cloud rows that exist only on another device and were never pulled here — + * otherwise the next sync would resurrect them. RLS scopes the result to the + * caller's own rows. Returns null when cloud is unavailable (offline / not + * configured / not signed in), in which case the local-only behaviour applies. + */ +export async function fetchCloudIds(): Promise<{ graphIds: string[]; projectIds: string[] } | null> { + if (!supabase) return null; + try { + const [graphsRes, projectsRes] = await Promise.all([ + supabase.from('graphs').select('id').eq('deleted', false), + supabase.from('projects').select('id').eq('deleted', false), + ]); + if (graphsRes.error || projectsRes.error) return null; + return { + graphIds: (graphsRes.data ?? []).map((r) => (r as { id: string }).id), + projectIds: (projectsRes.data ?? []).map((r) => (r as { id: string }).id), + }; + } catch { + return null; + } +} + +// ── Remote row shapes ──────────────────────────────────────────────────────── + +interface RemoteGraphRow { + id: string; + user_id?: string; + project_id: string | null; + title: string; + data: Graph | Record; + created_at_ms: number; + last_modified: number; + deleted: boolean; +} + +interface RemoteProjectRow { + id: string; + user_id?: string; + name: string; + description: string; + color: string; + created_at_ms: number; + last_modified: number; + deleted: boolean; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(id: string): boolean { + return UUID_RE.test(id); +} + +/** + * Remote ids are uuid columns; data imported from very old backups may have + * non-uuid ids. Remap them (and graph→project references) before syncing. + */ +export function remapNonUuidIds(graphs: Graph[], projects: Project[]): { + graphs: Graph[]; projects: Project[]; changed: boolean; +} { + let changed = false; + const projectIdMap = new Map(); + + const newProjects = projects.map((p) => { + if (isUuid(p.id)) return p; + changed = true; + const newId = crypto.randomUUID(); + projectIdMap.set(p.id, newId); + return { ...p, id: newId }; + }); + + const newGraphs = graphs.map((g) => { + let next = g; + if (g.projectId && projectIdMap.has(g.projectId)) { + next = { ...next, projectId: projectIdMap.get(g.projectId) }; + changed = true; + } + if (!isUuid(next.id)) { + next = { ...next, id: crypto.randomUUID() }; + changed = true; + } + return next; + }); + + return { graphs: newGraphs, projects: newProjects, changed }; +} + +export interface SyncOutcome { + graphs: Graph[]; + projects: Project[]; + /** True when local state differs from what was passed in (apply it). */ + changedLocal: boolean; + pushed: number; + pulled: number; +} + +function graphToRow(g: Graph, userId: string): RemoteGraphRow { + return { + id: g.id, + user_id: userId, + project_id: g.projectId && isUuid(g.projectId) ? g.projectId : null, + title: g.diagramData?.title || g.title || '', + data: g, + created_at_ms: g.createdAt ?? 0, + last_modified: g.lastModified ?? 0, + deleted: false, + }; +} + +function projectToRow(p: Project, userId: string): RemoteProjectRow { + return { + id: p.id, + user_id: userId, + name: p.name, + description: p.description ?? '', + color: p.color ?? '#3b82f6', + created_at_ms: p.createdAt ?? 0, + last_modified: p.lastModified ?? 0, + deleted: false, + }; +} + +// Tombstone rows must carry the FULL column set for their table. postgrest-js +// upserts batch rows together and sends any key missing from a row as NULL, so +// a partial tombstone batched with a full alive row would write NULL into a +// NOT NULL column (e.g. created_at_ms) and the whole upsert fails. +function graphTombstoneRow(id: string, userId: string, deletedAt: number): RemoteGraphRow { + // Content is wiped on deletion. The graph's version history is removed by + // the graphs_purge_versions_on_delete trigger (see supabase/schema.sql), + // so it happens server-side no matter which client performed the delete. + return { + id, user_id: userId, project_id: null, title: '', data: {}, + created_at_ms: 0, last_modified: deletedAt, deleted: true, + }; +} + +function projectTombstoneRow(id: string, userId: string, deletedAt: number): RemoteProjectRow { + return { + id, user_id: userId, name: '', description: '', color: '#3b82f6', + created_at_ms: 0, last_modified: deletedAt, deleted: true, + }; +} + +/** + * Reconcile local graphs/projects with the cloud. Throws on hard failures + * (network, RLS) with a user-presentable message. + */ +export async function syncCloud(userId: string, localGraphsIn: Graph[], localProjectsIn: Project[]): Promise { + if (!supabase) throw new Error('Cloud sync is not available on this deployment.'); + + const remap = remapNonUuidIds(localGraphsIn, localProjectsIn); + const localGraphs = remap.graphs; + const localProjects = remap.projects; + let changedLocal = remap.changed; + + const tombs = loadTombstones(); + + const [graphRes, projectRes] = await Promise.all([ + supabase.from('graphs').select('id, project_id, title, data, created_at_ms, last_modified, deleted'), + supabase.from('projects').select('id, name, description, color, created_at_ms, last_modified, deleted'), + ]); + if (graphRes.error) throw new Error(friendlySyncError(graphRes.error.message)); + if (projectRes.error) throw new Error(friendlySyncError(projectRes.error.message)); + + const remoteGraphs = (graphRes.data ?? []) as RemoteGraphRow[]; + const remoteProjects = (projectRes.data ?? []) as RemoteProjectRow[]; + + let pushed = 0; + let pulled = 0; + + // ── Projects ── + const projectRows: RemoteProjectRow[] = []; + const projectTombRows: RemoteProjectRow[] = []; + const projectTombIds = new Set(); // guard against pushing an id twice (ON CONFLICT 21000) + const finalProjects = new Map(localProjects.map((p) => [p.id, p])); + const remoteProjectMap = new Map(remoteProjects.map((r) => [r.id, r])); + + for (const remote of remoteProjects) { + const local = finalProjects.get(remote.id); + if (remote.deleted) { + if (local) { + if ((local.lastModified ?? 0) > remote.last_modified) { + projectRows.push(projectToRow(local, userId)); // resurrect + } else { + finalProjects.delete(remote.id); + changedLocal = true; + } + } + delete tombs.projects[remote.id]; // server already knows + continue; + } + if (local) { + if (remote.last_modified > (local.lastModified ?? 0)) { + finalProjects.set(remote.id, { + id: remote.id, + name: remote.name, + description: remote.description, + color: remote.color, + createdAt: remote.created_at_ms, + lastModified: remote.last_modified, + }); + changedLocal = true; + pulled++; + } else if (remote.last_modified < (local.lastModified ?? 0)) { + projectRows.push(projectToRow(local, userId)); + } + } else { + const tombTs = tombs.projects[remote.id]; + if (tombTs && tombTs >= remote.last_modified) { + projectTombRows.push(projectTombstoneRow(remote.id, userId, tombTs)); + projectTombIds.add(remote.id); + } else { + finalProjects.set(remote.id, { + id: remote.id, + name: remote.name, + description: remote.description, + color: remote.color, + createdAt: remote.created_at_ms, + lastModified: remote.last_modified, + }); + changedLocal = true; + pulled++; + } + } + } + for (const local of finalProjects.values()) { + if (!remoteProjectMap.has(local.id)) { + projectRows.push(projectToRow(local, userId)); + } + } + // Tombstones for local deletions the server hasn't heard about yet. + for (const [id, ts] of Object.entries(tombs.projects)) { + if (projectTombIds.has(id)) continue; // already queued above + const remote = remoteProjectMap.get(id); + if (remote && !remote.deleted && remote.last_modified <= ts) { + projectTombRows.push(projectTombstoneRow(id, userId, ts)); + projectTombIds.add(id); + } + } + + // ── Graphs ── + const graphRows: RemoteGraphRow[] = []; + const graphTombRows: RemoteGraphRow[] = []; + const graphTombIds = new Set(); + const finalGraphs = new Map(localGraphs.map((g) => [g.id, g])); + const remoteGraphMap = new Map(remoteGraphs.map((r) => [r.id, r])); + + const remoteRowToGraph = (row: RemoteGraphRow): Graph | null => { + const data = row.data as Graph; + if (!data || typeof data !== 'object' || !data.diagramData) return null; + return { ...data, id: row.id, lastModified: row.last_modified }; + }; + + for (const remote of remoteGraphs) { + const local = finalGraphs.get(remote.id); + if (remote.deleted) { + if (local) { + if ((local.lastModified ?? 0) > remote.last_modified) { + graphRows.push(graphToRow(local, userId)); // resurrect + } else { + finalGraphs.delete(remote.id); + changedLocal = true; + } + } + delete tombs.graphs[remote.id]; + continue; + } + if (local) { + if (remote.last_modified > (local.lastModified ?? 0)) { + const pulledGraph = remoteRowToGraph(remote); + if (pulledGraph) { + finalGraphs.set(remote.id, pulledGraph); + changedLocal = true; + pulled++; + } + } else if (remote.last_modified < (local.lastModified ?? 0)) { + graphRows.push(graphToRow(local, userId)); + } + } else { + const tombTs = tombs.graphs[remote.id]; + if (tombTs && tombTs >= remote.last_modified) { + graphTombRows.push(graphTombstoneRow(remote.id, userId, tombTs)); + graphTombIds.add(remote.id); + } else { + const pulledGraph = remoteRowToGraph(remote); + if (pulledGraph) { + finalGraphs.set(remote.id, pulledGraph); + changedLocal = true; + pulled++; + } + } + } + } + for (const local of finalGraphs.values()) { + if (!remoteGraphMap.has(local.id)) { + graphRows.push(graphToRow(local, userId)); + } + } + for (const [id, ts] of Object.entries(tombs.graphs)) { + if (graphTombIds.has(id)) continue; // already queued above + const remote = remoteGraphMap.get(id); + if (remote && !remote.deleted && remote.last_modified <= ts) { + graphTombRows.push(graphTombstoneRow(id, userId, ts)); + graphTombIds.add(id); + } + } + + // ── Push ── + const projectUpserts = [...projectRows, ...projectTombRows]; + if (projectUpserts.length > 0) { + const { error } = await supabase.from('projects').upsert(projectUpserts as never[]); + if (error) throw new Error(friendlySyncError(error.message)); + pushed += projectUpserts.length; + } + const graphUpserts = [...graphRows, ...graphTombRows]; + if (graphUpserts.length > 0) { + const { error } = await supabase.from('graphs').upsert(graphUpserts as never[]); + if (error) throw new Error(friendlySyncError(error.message)); + pushed += graphUpserts.length; + } + + saveTombstones(tombs); + + // ── Version snapshots for pushed (alive) graphs ── + if (graphRows.length > 0) { + // Only snapshot graphs whose content actually changed since their last + // version — skip pushes that merely bumped last_modified, so identical + // snapshots don't pile up in the free-tier DB. + const hashes = loadVersionHashes(); + const changedRows = graphRows.filter((row) => { + const h = contentHash(JSON.stringify(row.data)); + if (hashes[row.id] === h) return false; + hashes[row.id] = h; + return true; + }); + if (changedRows.length > 0) { + const versionRows = changedRows.map((row) => ({ + graph_id: row.id, + user_id: userId, + title: row.title, + data: row.data, + last_modified: row.last_modified, + })); + const { error } = await supabase.from('graph_versions').insert(versionRows as never[]); + if (!error) { + saveVersionHashes(hashes); + // Independent per-graph prunes — run them concurrently instead of a + // serial round-trip each, which stalls the debounced sync path. + await Promise.all( + changedRows.map((row) => + supabase!.rpc('prune_graph_versions', { p_graph: row.id, p_keep: VERSIONS_TO_KEEP }), + ), + ); + } + } + } + + // ── Keep share links fresh, drop shares of deleted content ── + await refreshShares(userId, finalGraphs, finalProjects, graphRows, graphTombRows.map((r) => r.id), projectTombRows.map((r) => r.id)); + + return { + graphs: Array.from(finalGraphs.values()), + projects: Array.from(finalProjects.values()), + changedLocal, + pushed, + pulled, + }; +} + +async function refreshShares( + userId: string, + finalGraphs: Map, + finalProjects: Map, + pushedGraphRows: RemoteGraphRow[], + deletedGraphIds: string[], + deletedProjectIds: string[], +): Promise { + if (!supabase) return; + try { + const { data: shares } = await supabase + .from('shares') + .select('id, kind, graph_id, project_id') + .eq('user_id', userId); + if (!shares || shares.length === 0) return; + + const pushedIds = new Set(pushedGraphRows.map((r) => r.id)); + const allGraphs = Array.from(finalGraphs.values()); + + // Each share touches a different row, so refresh them concurrently + // rather than one blocking round-trip after another. + await Promise.all(shares.map(async (share) => { + if (share.kind === 'graph' && share.graph_id) { + if (deletedGraphIds.includes(share.graph_id) || !finalGraphs.has(share.graph_id)) { + await supabase!.from('shares').delete().eq('id', share.id); + } else if (pushedIds.has(share.graph_id)) { + const graph = finalGraphs.get(share.graph_id)!; + await supabase!.from('shares') + .update({ payload: graphSharePayload(graph), updated_at: new Date().toISOString() }) + .eq('id', share.id); + } + } else if (share.kind === 'project' && share.project_id) { + if (deletedProjectIds.includes(share.project_id) || !finalProjects.has(share.project_id)) { + await supabase!.from('shares').delete().eq('id', share.id); + } else { + const project = finalProjects.get(share.project_id)!; + const memberPushed = allGraphs.some((g) => g.projectId === project.id && pushedIds.has(g.id)); + // A deleted member is no longer in `allGraphs`, so its id isn't in + // `pushedIds` — without this, deleting a diagram from a shared project + // would leave it in the publicly served payload. Any deletion this + // sync re-renders the payload (which now omits the deleted graphs). + const memberDeleted = deletedGraphIds.length > 0; + if (memberPushed || memberDeleted) { + await supabase!.from('shares') + .update({ payload: projectSharePayload(project, allGraphs), updated_at: new Date().toISOString() }) + .eq('id', share.id); + } + } + } + })); + } catch { + // Share refresh is best-effort; the next sync retries. + } +} + +function friendlySyncError(message: string): string { + if (isRlsDenied(message)) { + return 'Cloud sync is part of the Supporter plan. Your data is still saved locally in this browser.'; + } + if (/Failed to fetch|network/i.test(message)) { + return 'Could not reach the sync server. Your data is safe locally; sync will retry.'; + } + return `Sync failed: ${message}`; +} diff --git a/services/useCloudSync.ts b/services/useCloudSync.ts new file mode 100644 index 0000000..3e77510 --- /dev/null +++ b/services/useCloudSync.ts @@ -0,0 +1,149 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { syncCloud } from './sync'; +import { Graph, Project } from '../types'; + +export type SyncStatus = 'disabled' | 'idle' | 'syncing' | 'error' | 'offline'; + +export interface SyncState { + status: SyncStatus; + lastSyncedAt: number | null; + error: string | null; +} + +const DEBOUNCE_MS = 4000; +const FOCUS_SYNC_MIN_INTERVAL_MS = 60_000; + +interface UseCloudSyncOptions { + /** userId when signed in AND entitled to sync; null otherwise. */ + userId: string | null; + hasInitialized: boolean; + graphs: Graph[]; + projects: Project[]; + applyRemote: (graphs: Graph[], projects: Project[]) => void; +} + +/** + * Debounced, self-healing cloud sync loop. Local-first: never blocks the UI, + * never runs concurrently, re-queues itself when local state changes during + * a run (so remote merges never clobber in-flight edits). + */ +export function useCloudSync({ userId, hasInitialized, graphs, projects, applyRemote }: UseCloudSyncOptions): { + syncState: SyncState; + syncNow: () => void; +} { + const [syncState, setSyncState] = useState({ status: 'disabled', lastSyncedAt: null, error: null }); + + const graphsRef = useRef(graphs); + const projectsRef = useRef(projects); + graphsRef.current = graphs; + projectsRef.current = projects; + + const userIdRef = useRef(userId); + userIdRef.current = userId; + + const runningRef = useRef(false); + const rerunRef = useRef(false); + const timerRef = useRef(null); + const lastRunRef = useRef(0); + const applyRemoteRef = useRef(applyRemote); + applyRemoteRef.current = applyRemote; + + const runSync = useCallback(async () => { + const uid = userIdRef.current; + if (!uid) return; + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + setSyncState((s) => ({ ...s, status: 'offline' })); + return; + } + if (runningRef.current) { + rerunRef.current = true; + return; + } + runningRef.current = true; + setSyncState((s) => ({ ...s, status: 'syncing', error: null })); + + const startGraphs = graphsRef.current; + const startProjects = projectsRef.current; + + try { + const outcome = await syncCloud(uid, startGraphs, startProjects); + lastRunRef.current = Date.now(); + + const localMoved = graphsRef.current !== startGraphs || projectsRef.current !== startProjects; + if (outcome.changedLocal && !localMoved) { + applyRemoteRef.current(outcome.graphs, outcome.projects); + } else if (outcome.changedLocal && localMoved) { + // Local state advanced while we were syncing — run again rather + // than applying a stale merge. + rerunRef.current = true; + } + setSyncState({ status: 'idle', lastSyncedAt: Date.now(), error: null }); + } catch (err) { + setSyncState((s) => ({ + status: 'error', + lastSyncedAt: s.lastSyncedAt, + error: err instanceof Error ? err.message : 'Sync failed.', + })); + } finally { + runningRef.current = false; + if (rerunRef.current) { + rerunRef.current = false; + window.setTimeout(() => { void runSync(); }, 500); + } + } + }, []); + + const scheduleSync = useCallback((delay: number = DEBOUNCE_MS) => { + if (!userIdRef.current) return; + if (timerRef.current) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + timerRef.current = null; + void runSync(); + }, delay); + }, [runSync]); + + // Sync on becoming enabled (sign-in / entitlement load) + useEffect(() => { + if (!userId) { + setSyncState({ status: 'disabled', lastSyncedAt: null, error: null }); + if (timerRef.current) window.clearTimeout(timerRef.current); + return; + } + setSyncState((s) => (s.status === 'disabled' ? { ...s, status: 'idle' } : s)); + scheduleSync(200); + }, [userId, scheduleSync]); + + // Debounced sync on data changes + useEffect(() => { + if (!hasInitialized || !userId) return; + scheduleSync(); + }, [graphs, projects, hasInitialized, userId, scheduleSync]); + + // Refresh when the tab regains focus (cross-device edits) or comes online + useEffect(() => { + if (!userId) return; + const onVisible = () => { + if (document.visibilityState === 'visible' && Date.now() - lastRunRef.current > FOCUS_SYNC_MIN_INTERVAL_MS) { + scheduleSync(300); + } + }; + const onOnline = () => scheduleSync(300); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('online', onOnline); + return () => { + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('online', onOnline); + }; + }, [userId, scheduleSync]); + + // Cleanup + useEffect(() => () => { + if (timerRef.current) window.clearTimeout(timerRef.current); + }, []); + + const syncNow = useCallback(() => { + scheduleSync(0); + }, [scheduleSync]); + + return useMemo(() => ({ syncState, syncNow }), [syncState, syncNow]); +} diff --git a/supabase/schema.sql b/supabase/schema.sql new file mode 100644 index 0000000..5e0773c --- /dev/null +++ b/supabase/schema.sql @@ -0,0 +1,488 @@ +-- ============================================================================ +-- IB EconGraph AI — Supabase schema +-- Run this in the Supabase SQL editor (or `supabase db push`) on a fresh +-- project. Safe to re-run: statements are idempotent where possible. +-- +-- Tables: +-- profiles — one row per user; billing/entitlement state (Polar) +-- projects — synced project folders +-- graphs — synced graphs (full Graph JSON in `data`) +-- graph_versions — version history snapshots (pruned client-side) +-- shares — public view-only share links (unguessable slug ids) +-- templates — user's custom component templates +-- ai_usage — hosted AI generation counters, one row per user/month +-- +-- Entitlement model: +-- The Polar webhook (server, service role) writes pro_status / pro_until. +-- A user is "Pro" while pro_until > now(). Write access to synced data is +-- gated on is_pro(); read access is owner-only but NOT pro-gated, so users +-- whose subscription lapsed can always retrieve their data. +-- ============================================================================ + +create extension if not exists pgcrypto; + +-- ---------------------------------------------------------------------------- +-- profiles +-- ---------------------------------------------------------------------------- +create table if not exists public.profiles ( + id uuid primary key references auth.users (id) on delete cascade, + email text, + display_name text, + -- Supporter recognition (opt-in name listed in the README) + supporter_name text, + show_in_supporters boolean not null default false, + -- Billing state, written only by the Polar webhook via service role + pro_status text not null default 'none', + pro_until timestamptz, + plan_interval text, + polar_customer_id text, + polar_subscription_id text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table public.profiles enable row level security; + +-- Create a profile row automatically for every new auth user. +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.profiles (id, email) + values (new.id, new.email) + on conflict (id) do nothing; + return new; +end; +$$; + +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); + +-- Trigger-only. It must stay SECURITY DEFINER (it inserts the profile row before +-- any user session exists), but it should never be callable via the REST API — +-- revoke EXECUTE so it isn't exposed as an RPC (DB linter 0028/0029). +revoke execute on function public.handle_new_user() from public, anon, authenticated; + +-- Entitlement check used by RLS policies below. SECURITY INVOKER (runs as the +-- caller): every policy calls it as is_pro(auth.uid()), so under the profiles +-- SELECT policy it can only ever read the caller's own row. Kept out of +-- SECURITY DEFINER on purpose — a definer function exposed via PostgREST is +-- what the DB linter (0028/0029) flags, and it isn't needed here. +create or replace function public.is_pro(p_user uuid) +returns boolean +language sql +stable +security invoker +set search_path = public +as $$ + select exists ( + select 1 from public.profiles + where id = p_user + and pro_until is not null + and pro_until > now() + ); +$$; + +revoke execute on function public.is_pro(uuid) from public; +grant execute on function public.is_pro(uuid) to authenticated; + +drop policy if exists "profiles: select own" on public.profiles; +create policy "profiles: select own" + on public.profiles for select + using (auth.uid() = id); + +drop policy if exists "profiles: update own" on public.profiles; +create policy "profiles: update own" + on public.profiles for update + using (auth.uid() = id) + with check (auth.uid() = id); + +-- Users may only edit their harmless profile columns; billing columns are +-- writable exclusively via the service role (column-level privileges). +revoke update on public.profiles from authenticated; +grant update (display_name, supporter_name, show_in_supporters) + on public.profiles to authenticated; + +-- ---------------------------------------------------------------------------- +-- projects +-- ---------------------------------------------------------------------------- +create table if not exists public.projects ( + id uuid primary key, + user_id uuid not null references auth.users (id) on delete cascade, + name text not null default '', + description text not null default '', + color text not null default '#3b82f6', + created_at_ms bigint not null default 0, + last_modified bigint not null default 0, + deleted boolean not null default false, + updated_at timestamptz not null default now() +); + +create index if not exists projects_user_idx on public.projects (user_id); + +alter table public.projects enable row level security; + +drop policy if exists "projects: select own" on public.projects; +create policy "projects: select own" + on public.projects for select + using (auth.uid() = user_id); + +drop policy if exists "projects: insert own (pro)" on public.projects; +create policy "projects: insert own (pro)" + on public.projects for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "projects: update own (pro)" on public.projects; +create policy "projects: update own (pro)" + on public.projects for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "projects: delete own" on public.projects; +create policy "projects: delete own" + on public.projects for delete + using (auth.uid() = user_id); + +-- ---------------------------------------------------------------------------- +-- graphs +-- ---------------------------------------------------------------------------- +create table if not exists public.graphs ( + id uuid primary key, + user_id uuid not null references auth.users (id) on delete cascade, + project_id uuid, + title text not null default '', + data jsonb not null default '{}'::jsonb, + created_at_ms bigint not null default 0, + last_modified bigint not null default 0, + deleted boolean not null default false, + updated_at timestamptz not null default now() +); + +create index if not exists graphs_user_idx on public.graphs (user_id); + +alter table public.graphs enable row level security; + +drop policy if exists "graphs: select own" on public.graphs; +create policy "graphs: select own" + on public.graphs for select + using (auth.uid() = user_id); + +drop policy if exists "graphs: insert own (pro)" on public.graphs; +create policy "graphs: insert own (pro)" + on public.graphs for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "graphs: update own (pro)" on public.graphs; +create policy "graphs: update own (pro)" + on public.graphs for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "graphs: delete own" on public.graphs; +create policy "graphs: delete own" + on public.graphs for delete + using (auth.uid() = user_id); + +-- ---------------------------------------------------------------------------- +-- graph_versions — snapshots written on every synced change, pruned to the +-- most recent N per graph by the client via prune_graph_versions(). +-- ---------------------------------------------------------------------------- +create table if not exists public.graph_versions ( + id uuid primary key default gen_random_uuid(), + graph_id uuid not null, + user_id uuid not null references auth.users (id) on delete cascade, + title text not null default '', + data jsonb not null default '{}'::jsonb, + last_modified bigint not null default 0, + created_at timestamptz not null default now() +); + +create index if not exists graph_versions_graph_idx + on public.graph_versions (graph_id, created_at desc); + +alter table public.graph_versions enable row level security; + +drop policy if exists "graph_versions: select own" on public.graph_versions; +create policy "graph_versions: select own" + on public.graph_versions for select + using (auth.uid() = user_id); + +drop policy if exists "graph_versions: insert own (pro)" on public.graph_versions; +create policy "graph_versions: insert own (pro)" + on public.graph_versions for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "graph_versions: delete own" on public.graph_versions; +create policy "graph_versions: delete own" + on public.graph_versions for delete + using (auth.uid() = user_id); + +-- Hard ceiling on stored versions per graph, enforced by the database itself. +-- The client asks for 30 (VERSIONS_TO_KEEP), but p_keep below is caller-supplied +-- and a tampered client could pass a huge value, or simply never call prune at +-- all, and grow this table without bound. The insert trigger further down makes +-- the cap unavoidable, so neither trick works. +create or replace function public.graph_version_cap() +returns integer +language sql +immutable +as $$ select 100 $$; + +create or replace function public.prune_graph_versions(p_graph uuid, p_keep integer default 30) +returns void +language sql +security invoker +set search_path = public +as $$ + delete from public.graph_versions + where graph_id = p_graph + and user_id = auth.uid() + and id not in ( + select id from public.graph_versions + where graph_id = p_graph and user_id = auth.uid() + order by created_at desc + -- Clamped to [1, cap]: a caller cannot request an unbounded keep count. + limit least(greatest(p_keep, 1), public.graph_version_cap()) + ); +$$; + +grant execute on function public.prune_graph_versions(uuid, integer) to authenticated; + +-- Enforce the cap on every insert, so retention never depends on the client +-- choosing to call prune_graph_versions(). SECURITY DEFINER because it must +-- delete rows during the caller's insert; it only ever touches the same +-- (graph_id, user_id) pair that was just inserted, so it cannot reach another +-- user's data. Trigger-only, so EXECUTE is revoked (DB linter 0028/0029). +create or replace function public.enforce_graph_version_cap() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + delete from public.graph_versions + where graph_id = new.graph_id + and user_id = new.user_id + and id not in ( + select id from public.graph_versions + where graph_id = new.graph_id and user_id = new.user_id + order by created_at desc + limit public.graph_version_cap() + ); + return null; +end; +$$; + +revoke execute on function public.enforce_graph_version_cap() from public, anon, authenticated; + +drop trigger if exists graph_versions_enforce_cap on public.graph_versions; +create trigger graph_versions_enforce_cap + after insert on public.graph_versions + for each row execute function public.enforce_graph_version_cap(); + +-- Deleting a graph must take its history with it. Deletion is a soft delete +-- (a tombstone row with deleted = true, so other devices learn about it), and +-- graph_versions has no FK to graphs, so nothing would otherwise ever remove +-- these rows: they would sit in the table until the whole account is deleted. +-- Doing it in the database means it also covers deletes from an older client. +create or replace function public.purge_versions_for_deleted_graph() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + if new.deleted and not coalesce(old.deleted, false) then + delete from public.graph_versions + where graph_id = new.id and user_id = new.user_id; + end if; + return null; +end; +$$; + +revoke execute on function public.purge_versions_for_deleted_graph() from public, anon, authenticated; + +drop trigger if exists graphs_purge_versions_on_delete on public.graphs; +create trigger graphs_purge_versions_on_delete + after insert or update of deleted on public.graphs + for each row execute function public.purge_versions_for_deleted_graph(); + +-- ---------------------------------------------------------------------------- +-- shares — view-only snapshots addressed by an unguessable slug. +-- Payloads contain diagram data only (never chat history). +-- +-- Anonymous access is served ONLY through the get_share() RPC below, which +-- returns just the payload for an exact slug match. The table itself is NOT +-- readable by anon: a blanket `using (true)` SELECT policy would let anyone +-- holding the public publishable key (which authenticates as the `anon` role) +-- bulk-enumerate every share's payload and owner user_id via PostgREST, +-- defeating the point of unguessable slugs. +-- ---------------------------------------------------------------------------- +create table if not exists public.shares ( + id text primary key, + user_id uuid not null references auth.users (id) on delete cascade, + kind text not null check (kind in ('graph', 'project')), + graph_id uuid, + project_id uuid, + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists shares_user_idx on public.shares (user_id); +create index if not exists shares_graph_idx on public.shares (graph_id); +create index if not exists shares_project_idx on public.shares (project_id); + +alter table public.shares enable row level security; + +-- Owners can read their own share rows (needed for getShareIdFor* / refresh). +-- Public read goes through get_share() instead of a table policy. +drop policy if exists "shares: public read" on public.shares; +drop policy if exists "shares: select own" on public.shares; +create policy "shares: select own" + on public.shares for select + using (auth.uid() = user_id); + +revoke select on public.shares from anon; + +-- Anonymous slug lookup: returns only the payload, only for an exact id match. +-- No enumeration (must know the 96-bit slug), no user_id / graph_id leakage. +-- NOTE: The DB linter (0028/0029) flags this as an anon-executable SECURITY +-- DEFINER function. That is INTENTIONAL and required: anonymous visitors must +-- resolve a share link without a session, and it must bypass the shares RLS +-- (which is otherwise owner-only). It's safe because it takes an exact, +-- unguessable id and returns nothing but that row's payload. Leave as-is. +create or replace function public.get_share(p_id text) +returns jsonb +language sql +stable +security definer +set search_path = public +as $$ + select payload from public.shares where id = p_id; +$$; + +revoke execute on function public.get_share(text) from public; +grant execute on function public.get_share(text) to anon, authenticated; + +drop policy if exists "shares: insert own (pro)" on public.shares; +create policy "shares: insert own (pro)" + on public.shares for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "shares: update own (pro)" on public.shares; +create policy "shares: update own (pro)" + on public.shares for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "shares: delete own" on public.shares; +create policy "shares: delete own" + on public.shares for delete + using (auth.uid() = user_id); + +-- ---------------------------------------------------------------------------- +-- templates — user's custom component templates (synced) +-- ---------------------------------------------------------------------------- +create table if not exists public.templates ( + id uuid primary key, + user_id uuid not null references auth.users (id) on delete cascade, + name text not null default '', + description text not null default '', + category text not null default 'custom', + data jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + last_modified bigint not null default 0 +); + +create index if not exists templates_user_idx on public.templates (user_id); + +alter table public.templates enable row level security; + +drop policy if exists "templates: select own" on public.templates; +create policy "templates: select own" + on public.templates for select + using (auth.uid() = user_id); + +drop policy if exists "templates: insert own (pro)" on public.templates; +create policy "templates: insert own (pro)" + on public.templates for insert + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "templates: update own (pro)" on public.templates; +create policy "templates: update own (pro)" + on public.templates for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id and public.is_pro(auth.uid())); + +drop policy if exists "templates: delete own" on public.templates; +create policy "templates: delete own" + on public.templates for delete + using (auth.uid() = user_id); + +-- ---------------------------------------------------------------------------- +-- ai_usage — hosted AI metering. Written only by the server (service role) +-- through the atomic functions below. Users can read their own row. +-- ---------------------------------------------------------------------------- +create table if not exists public.ai_usage ( + user_id uuid not null references auth.users (id) on delete cascade, + month text not null, -- 'YYYY-MM' (UTC) + count integer not null default 0, + updated_at timestamptz not null default now(), + primary key (user_id, month) +); + +alter table public.ai_usage enable row level security; + +drop policy if exists "ai_usage: select own" on public.ai_usage; +create policy "ai_usage: select own" + on public.ai_usage for select + using (auth.uid() = user_id); + +-- Atomically increment usage if under the limit. Returns the new count, or +-- -1 when the limit has been reached (row unchanged). +create or replace function public.increment_ai_usage(p_user uuid, p_month text, p_limit integer) +returns integer +language plpgsql +security definer +set search_path = public +as $$ +declare + new_count integer; +begin + insert into public.ai_usage (user_id, month, count) + values (p_user, p_month, 1) + on conflict (user_id, month) do update + set count = ai_usage.count + 1, + updated_at = now() + where ai_usage.count < p_limit + returning count into new_count; + + if new_count is null then + return -1; + end if; + return new_count; +end; +$$; + +-- Refund one generation (used when the upstream AI call fails after metering). +create or replace function public.refund_ai_usage(p_user uuid, p_month text) +returns void +language sql +security definer +set search_path = public +as $$ + update public.ai_usage + set count = greatest(count - 1, 0), + updated_at = now() + where user_id = p_user and month = p_month; +$$; + +-- These are only ever called with the service role key. +revoke execute on function public.increment_ai_usage(uuid, text, integer) from public, anon, authenticated; +revoke execute on function public.refund_ai_usage(uuid, text) from public, anon, authenticated; diff --git a/vercel.json b/vercel.json index 1323cda..d19a8f6 100644 --- a/vercel.json +++ b/vercel.json @@ -1,7 +1,9 @@ { + "cleanUrls": true, + "trailingSlash": false, "rewrites": [ { - "source": "/(.*)", + "source": "/((?!api/).*)", "destination": "/index.html" } ] diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 0000000..85d897e --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_SUPABASE_URL?: string; + readonly VITE_SUPABASE_PUBLISHABLE_KEY?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index b35f9b9..691833e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,23 +1,134 @@ import path from 'path'; -import { defineConfig, loadEnv } from 'vite'; +import fs from 'node:fs'; +import { defineConfig, loadEnv, type Plugin, type ViteDevServer } from 'vite'; import react from '@vitejs/plugin-react'; -export default defineConfig(({ mode }) => { +/** + * Dev-only shim: serve the Vercel serverless functions in `api/` directly from + * the Vite dev server, so `npm run dev` exercises the real handlers (checkout, + * usage, portal, webhooks…) without needing `vercel dev`. It maps `/api/` + * to `api/.ts`, runs the module's default export, and adapts Node's + * req/res to the small slice of the Vercel Node API the handlers use + * (`req.query`, `req.body`, `res.status().json()`…). Production still runs on + * the real Vercel runtime — this only exists for `command === 'serve'`. + */ +function devApiPlugin(root: string): Plugin { + return { + name: 'dev-api-functions', + apply: 'serve', + configureServer(server: ViteDevServer) { + // Registering here (not in a returned callback) runs the middleware + // before Vite's SPA history fallback, so /api isn't rewritten to index.html. + server.middlewares.use(async (req: any, res: any, next: () => void) => { + if (!req.url || !req.url.startsWith('/api/')) return next(); + + const parsed = new URL(req.url, 'http://localhost'); + const rel = parsed.pathname.replace(/^\/api\//, '').replace(/\/+$/, ''); + const variants = [ + { abs: path.join(root, 'api', `${rel}.ts`), id: `/api/${rel}.ts` }, + { abs: path.join(root, 'api', rel, 'index.ts'), id: `/api/${rel}/index.ts` }, + ]; + const match = variants.find((v) => fs.existsSync(v.abs)); + if (!match) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: `No API route for ${parsed.pathname}` })); + return; + } + + // Vercel-style request extras. + req.query = Object.fromEntries(parsed.searchParams); + // Webhook handlers read the raw body themselves (bodyParser is + // disabled), so leave their stream untouched. Everything else + // gets a parsed JSON body. + if (!parsed.pathname.startsWith('/api/webhooks/')) { + req.body = await readJsonBody(req); + } + + // Vercel-style response helpers. + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (obj: unknown) => { + if (!res.getHeader('Content-Type')) res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(obj)); + return res; + }; + res.send = (data: unknown) => { + res.end(typeof data === 'string' || Buffer.isBuffer(data) ? data : JSON.stringify(data)); + return res; + }; + res.redirect = (url: string) => { + res.statusCode = 302; + res.setHeader('Location', url); + res.end(); + return res; + }; + + try { + const mod = await server.ssrLoadModule(match.id); + const handler = mod.default as ((req: unknown, res: unknown) => unknown) | undefined; + if (typeof handler !== 'function') { + throw new Error(`API route ${rel} has no default export handler`); + } + await handler(req, res); + } catch (err) { + server.config.logger.error(`[dev-api] ${rel} failed:\n${(err as Error).stack || err}`); + if (!res.writableEnded) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Dev API handler error (see terminal).' })); + } + } + }); + }, + }; +} + +function readJsonBody(req: any): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + if (chunks.length === 0) return resolve(undefined); + const raw = Buffer.concat(chunks).toString('utf8'); + const ct = String(req.headers['content-type'] || ''); + if (ct.includes('application/json')) { + try { resolve(JSON.parse(raw)); } catch { resolve(undefined); } + } else { + resolve(raw); + } + }); + req.on('error', () => resolve(undefined)); + }); +} + +export default defineConfig(({ mode, command }) => { const env = loadEnv(mode, '.', ''); + if (command === 'serve') { + // Expose server-side vars (SUPABASE_SECRET_KEY, POLAR_*, GEMINI_API_KEY…) + // to the dev API handlers, which run in this Node process via ssrLoadModule. + // Does not affect the client bundle — only VITE_-prefixed vars reach that. + for (const [k, v] of Object.entries(env)) { + if (process.env[k] === undefined) process.env[k] = v; + } + } return { - server: { - port: 4000, - host: '0.0.0.0', - }, - plugins: [react()], - define: { - 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), - 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) - }, - resolve: { - alias: { - '@': path.resolve(__dirname, '.'), + server: { + port: 4000, + host: '0.0.0.0', + // Allow access through public dev tunnels (used for testing the + // Polar webhook/redirect against a real HTTPS origin). Vite otherwise + // rejects non-localhost Host headers with "This host is not allowed". + allowedHosts: ['.devtunnels.ms', '.ngrok-free.app', '.trycloudflare.com'], + }, + plugins: [react(), devApiPlugin(__dirname)], + define: { + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + } } - } }; }); From 052ff2a5bfa8a8a1af2e6f8991881f4cf002cab2 Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Mon, 27 Jul 2026 21:59:13 +0300 Subject: [PATCH 02/29] Fix bugs found in PR review Licensing (the project is AGPL-3.0, two places still claimed MIT): - package.json: license MIT -> AGPL-3.0-or-later - scripts/seo-content.mjs: the PPC landing page told visitors the project was MIT open source, contradicting its own footer Correctness / security: - api/generate.ts: a failed profile lookup returned 402 not_pro, telling a paying Supporter their plan had lapsed during a transient DB blip. Return 503 instead. - api/generate.ts: the quota gate only tripped on `typeof newCount === 'number'`, so an unexpected RPC return type silently skipped metering and handed out unlimited generations on the hosted key. Fail closed. - api/usage.ts: the ai_usage query error was ignored, reporting `used: 0` on failure and showing a full quota to someone who had spent it. - api/webhooks/polar.ts: the profile SELECT error was ignored, so a failed read looked like "nothing on file" and could clobber the live subscription. Throw so the handler answers 500 and Polar retries. - api/delete-account.ts: cancellation was gated on our own pro_status, so a stale value skipped it and left a subscription billing a deleted account. Always attempt it when a subscription id is on file. Also, any Polar lookup failure was read as "already gone"; only a 404 proves that now. - services/shares.ts: the existing-share lookup discarded its error, so a transient failure read as "no share exists" and minted a second slug for the same content. Revoking the link shown in the UI then left the other one publicly readable. Surface the failure, and resolve a lost creation race to the winning link. - supabase/schema.sql: revoke a share when its graph or project is deleted. The client already prunes these during sync, but that pass is best-effort and swallows failures, leaving deleted diagrams publicly readable. - supabase/schema.sql: unique index for one share per graph/project, with a dedupe of any rows predating it so the migration applies to a live database. UI / client: - App.tsx: derive the preserved graph title from current state rather than the snapshot taken before the await, so renaming during generation still wins. - App.tsx: blank the canvas and undo stack on account switch. Clearing the collections alone left the previous account's diagram on screen until the first cloud pull landed. - components/LandingPage.tsx: drop `font-small`, not a Tailwind class. - services/hostedAi.ts: a failed session restore rejected instead of returning null, producing an unhandled rejection in the usage meter. Build / config: - scripts/generate-seo-pages.mjs: a trailing `_` or `^` in a label hung the build forever. The scan could not advance past the marker, so the outer loop never progressed. - vite.config.ts: drop the `define` entries that inlined GEMINI_API_KEY into the client bundle. Nothing referenced them, but any future code that did would have shipped the server key to the browser. Schema changes verified against a throwaway Postgres: applies cleanly, is idempotent, collapses pre-existing duplicate shares, and the delete triggers and unique indexes behave. --- App.tsx | 27 ++++++++++++--- api/delete-account.ts | 15 +++++++-- api/generate.ts | 21 +++++++++--- api/usage.ts | 7 ++++ api/webhooks/polar.ts | 8 ++++- components/LandingPage.tsx | 2 +- package.json | 2 +- scripts/generate-seo-pages.mjs | 6 ++-- scripts/seo-content.mjs | 2 +- services/hostedAi.ts | 6 ++-- services/shares.ts | 61 ++++++++++++++++++++++++++++++---- supabase/schema.sql | 60 +++++++++++++++++++++++++++++++++ vite.config.ts | 8 ++--- 13 files changed, 195 insertions(+), 30 deletions(-) diff --git a/App.tsx b/App.tsx index 6947103..f6042e5 100644 --- a/App.tsx +++ b/App.tsx @@ -268,6 +268,14 @@ export default function App() { setGraphs([]); setProjects([]); setActiveGraphId(null); + // Also blank the canvas and its undo stack. Clearing the collections + // alone leaves the previous account's open diagram on screen until the + // first cloud pull lands, which is exactly what this guard is for. + const blank = { ...EMPTY_DIAGRAM }; + setCurrentDiagram(blank); + setHistory([blank]); + historyRef.current = [blank]; + setHistoryIndex(0); } try { localStorage.setItem(STORAGE_KEYS.owner, uid); } catch { /* ignore */ } }, [user?.id, hasInitialized]); @@ -735,6 +743,12 @@ export default function App() { [graphs, activeGraphId] ); + // Latest graphs, readable from async callbacks that would otherwise close + // over the snapshot taken before an `await` (e.g. a rename the user makes + // while a generation is still in flight). + const graphsRef = useRef(graphs); + graphsRef.current = graphs; + const projectGraphs = useMemo(() => { if (!activeGraph) return []; if (activeGraph.projectId) { @@ -821,11 +835,14 @@ export default function App() { // Only let the AI name the graph while it still has the default title. // Once the user has renamed it, that name is theirs and a later - // generation must not silently overwrite it. - const userNamed = !!activeGraph - && activeGraph.title.trim() !== '' - && activeGraph.title !== EMPTY_DIAGRAM.title; - const nextDiagram = userNamed ? { ...result, title: activeGraph!.title } : result; + // generation must not silently overwrite it. Read the title as it is + // *now*, not as it was when the request was sent, so a rename made while + // this was generating still wins. + const liveGraph = graphsRef.current.find(g => g.id === activeGraphId) || null; + const userNamed = !!liveGraph + && liveGraph.title.trim() !== '' + && liveGraph.title !== EMPTY_DIAGRAM.title; + const nextDiagram = userNamed ? { ...result, title: liveGraph!.title } : result; const aiMsg: Message = { id: generateId(), diff --git a/api/delete-account.ts b/api/delete-account.ts index 62e766b..4d459df 100644 --- a/api/delete-account.ts +++ b/api/delete-account.ts @@ -45,7 +45,11 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { }); } - if (profile?.polar_subscription_id && ACTIVE_STATUSES.has(profile.pro_status)) { + // Try to cancel whenever a subscription id is on file, without trusting our + // own pro_status: if that column is stale (a missed webhook), gating on it + // would skip cancellation and leave a live subscription billing a deleted + // account. Revoking something already inactive is handled below. + if (profile?.polar_subscription_id) { const subId = profile.polar_subscription_id; try { await getPolar().subscriptions.revoke({ id: subId }); @@ -58,8 +62,13 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { try { const sub = await getPolar().subscriptions.get({ id: subId }); stillActive = ACTIVE_STATUSES.has(sub.status ?? ''); - } catch { - stillActive = false; // e.g. 404 not found → already gone + } catch (lookupErr) { + // Only a definite "not found" proves the subscription is gone. + // Treating any failure as gone would delete the account during a + // Polar outage and orphan a subscription that keeps charging. + const status = (lookupErr as { statusCode?: number; status?: number } | null)?.statusCode + ?? (lookupErr as { status?: number } | null)?.status; + stillActive = status !== 404; } if (stillActive) { console.error('delete-account: subscription cancel failed', err); diff --git a/api/generate.ts b/api/generate.ts index efc3878..8f81677 100644 --- a/api/generate.ts +++ b/api/generate.ts @@ -121,10 +121,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } } - const profile = await getProfile(user.id).catch((err) => { + // A failed lookup is not the same as "not a Supporter" — answering 402 here + // would tell a paying user their plan lapsed during a transient DB blip. + let profile; + try { + profile = await getProfile(user.id); + } catch (err) { console.error('generate: profile lookup failed', err); - return null; - }); + return res.status(503).json({ + error: 'Could not confirm your plan right now. Please try again in a moment.', + }); + } if (!isProfilePro(profile)) { return res.status(402).json({ error: 'Hosted AI is part of the Supporter plan. You can keep generating for free with your own API key (Settings > AI Provider).', @@ -145,7 +152,13 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { console.error('generate: usage metering failed', usageError); return res.status(500).json({ error: 'Usage metering failed. Please try again.' }); } - if (typeof newCount === 'number' && newCount < 0) { + // Fail closed: an unexpected return type must not skip the quota check and + // hand out unmetered generations on the hosted key. + if (typeof newCount !== 'number') { + console.error('generate: increment_ai_usage returned a non-numeric result', newCount); + return res.status(500).json({ error: 'Usage metering failed. Please try again.' }); + } + if (newCount < 0) { return res.status(429).json({ error: `You've used all ${limit} hosted generations for this month. They reset at the start of next month, or add your own free API key in Settings for unlimited generations.`, code: 'quota_exceeded', diff --git a/api/usage.ts b/api/usage.ts index 6334043..e553246 100644 --- a/api/usage.ts +++ b/api/usage.ts @@ -36,6 +36,13 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { .maybeSingle(), ]); + // A failed lookup must not masquerade as "0 used" — that would show a + // full quota to someone who has already spent it. + if (usageResult.error) { + console.error('usage: failed to read ai_usage', usageResult.error); + return res.status(503).json({ error: 'Usage service is temporarily unavailable.' }); + } + const used = usageResult.data?.count ?? 0; return res.status(200).json({ used, diff --git a/api/webhooks/polar.ts b/api/webhooks/polar.ts index 812c6fa..201e873 100644 --- a/api/webhooks/polar.ts +++ b/api/webhooks/polar.ts @@ -53,11 +53,17 @@ async function applySubscriptionState(sub: SubscriptionLike): Promise { // Read what's currently on file so out-of-order or superseded events for a // DIFFERENT subscription can't clobber the one the user is actually on // (e.g. after cancel + resubscribe, a delayed event for the old sub). - const { data: current } = await admin + const { data: current, error: currentError } = await admin .from('profiles') .select('polar_subscription_id, pro_until') .eq('id', userId) .maybeSingle(); + if (currentError) { + // Without the current row we can't tell a superseded event from a live + // one. Throwing makes the handler answer 500 so Polar retries, which is + // safer than guessing and possibly revoking an active subscription. + throw new Error(`could not read profile ${userId}: ${currentError.message}`); + } const onFile = current?.polar_subscription_id; const differentSub = !!onFile && onFile !== sub.id; const DAY_MS = 24 * 60 * 60 * 1000; diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index e54e2bc..e7b900e 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -269,7 +269,7 @@ const LandingPage: React.FC = ({ onGoHome, onOpenPricing, onOp -

    +

    Everything a student needs to finish their IA is free and unlimited, forever.

    diff --git a/package.json b/package.json index 6bc37f6..4e40bdd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.1.0", "description": "Free and open-source AI-powered economics diagram editor built for IB students and educators.", "type": "module", - "license": "MIT", + "license": "AGPL-3.0-or-later", "author": { "name": "Sukarth Acharya", "url": "https://github.com/sukarth" diff --git a/scripts/generate-seo-pages.mjs b/scripts/generate-seo-pages.mjs index 8ea7418..e8d9508 100644 --- a/scripts/generate-seo-pages.mjs +++ b/scripts/generate-seo-pages.mjs @@ -56,8 +56,10 @@ function svgLabel(text) { i += consumed; } else { // Gather the whole plain-text run and emit it once, applying any - // pending baseline reset to it. - let j = i; + // pending baseline reset to it. A trailing '_' or '^' has nothing + // to mark up and lands here, so always consume the character at i + // to guarantee the outer loop makes progress. + let j = i + 1; while (j < text.length && text[j] !== '_' && text[j] !== '^') j += 1; const run = text.slice(i, j); out += pendingReset !== null diff --git a/scripts/seo-content.mjs b/scripts/seo-content.mjs index da4367c..abab4c9 100644 --- a/scripts/seo-content.mjs +++ b/scripts/seo-content.mjs @@ -454,7 +454,7 @@ export const DIAGRAM_PAGES = [ faq: [ ['Can I draw both straight and curved PPCs?', 'Yes, the line tool gives constant opportunity cost, the bezier tool gives the standard concave frontier.'], ['How do I show economic growth?', 'Duplicate the curve and drag it outward (or ask the AI for "PPC with outward shift showing potential growth").'], - ['Is this free for classroom use?', 'Completely, teachers and students can use everything without accounts or licences, and the project is MIT open source.'], + ['Is this free for classroom use?', 'Completely, teachers and students can use everything without accounts or licences, and the project is open source under the AGPL-3.0.'], ], axes: ['Consumer goods', 'Capital goods'], diagram: { diff --git a/services/hostedAi.ts b/services/hostedAi.ts index 6ac0fda..0756381 100644 --- a/services/hostedAi.ts +++ b/services/hostedAi.ts @@ -46,9 +46,11 @@ export async function generateDiagramDataHosted(prompt: string, history: string[ /** Fetch the signed-in user's hosted AI usage. Returns null when unavailable. */ export async function fetchHostedUsage(): Promise { - const token = await getAccessToken(); - if (!token) return null; try { + // Inside the try: a failed session restore should read as "no usage to + // show", not reject and leave callers with an unhandled rejection. + const token = await getAccessToken(); + if (!token) return null; const res = await fetch('/api/usage', { headers: { Authorization: `Bearer ${token}` }, }); diff --git a/services/shares.ts b/services/shares.ts index 582190e..effb4a4 100644 --- a/services/shares.ts +++ b/services/shares.ts @@ -60,6 +60,34 @@ export function projectSharePayload(project: Project, graphs: Graph[]): ProjectS }; } +/** Postgres unique_violation — the one-share-per-content indexes fired. */ +function isDuplicateShare(error: { code?: string; message?: string }): boolean { + return error.code === '23505' || /duplicate key value/i.test(error.message ?? ''); +} + +/** + * Look up the existing share for a piece of content, keeping "none exists" + * distinct from "the lookup failed". Callers that mint a new slug MUST NOT + * treat a failure as "none": that would create a second share row for the same + * content, and revoking the one the UI shows would leave the other link live. + */ +async function findShareId( + kind: 'graph' | 'project', + column: 'graph_id' | 'project_id', + contentId: string, +): Promise<{ id: string | null; failed: boolean }> { + if (!supabase) return { id: null, failed: true }; + const { data, error } = await supabase + .from('shares') + .select('id') + .eq('kind', kind) + .eq(column, contentId) + .limit(1) + .maybeSingle(); + if (error) return { id: null, failed: true }; + return { id: data?.id ?? null, failed: false }; +} + export async function getShareIdForGraph(graphId: string): Promise { if (!supabase) return null; const { data } = await supabase @@ -86,8 +114,11 @@ export async function getShareIdForProject(projectId: string): Promise { if (!supabase) return { error: 'Sharing is not available on this deployment.' }; - const existing = await getShareIdForGraph(graph.id); - const id = existing ?? newShareSlug(); + const existing = await findShareId('graph', 'graph_id', graph.id); + if (existing.failed) { + return { error: 'Could not check for an existing link right now. Please try again in a moment.' }; + } + const id = existing.id ?? newShareSlug(); const { error } = await supabase.from('shares').upsert({ id, user_id: userId, @@ -97,7 +128,16 @@ export async function createOrUpdateGraphShare(userId: string, graph: Graph): Pr payload: graphSharePayload(graph), updated_at: new Date().toISOString(), }); - if (error) return { error: friendlyShareError(error.message) }; + if (error) { + // Lost a race: another tab created the link between our lookup and this + // insert, and the one-share-per-graph index rejected the second slug. + // Hand back the link that won rather than surfacing a database error. + if (isDuplicateShare(error)) { + const winner = await findShareId('graph', 'graph_id', graph.id); + if (winner.id) return { id: winner.id }; + } + return { error: friendlyShareError(error.message) }; + } return { id }; } @@ -107,8 +147,11 @@ export async function createOrUpdateProjectShare( graphs: Graph[], ): Promise<{ id?: string; error?: string }> { if (!supabase) return { error: 'Sharing is not available on this deployment.' }; - const existing = await getShareIdForProject(project.id); - const id = existing ?? newShareSlug(); + const existing = await findShareId('project', 'project_id', project.id); + if (existing.failed) { + return { error: 'Could not check for an existing link right now. Please try again in a moment.' }; + } + const id = existing.id ?? newShareSlug(); const { error } = await supabase.from('shares').upsert({ id, user_id: userId, @@ -118,7 +161,13 @@ export async function createOrUpdateProjectShare( payload: projectSharePayload(project, graphs), updated_at: new Date().toISOString(), }); - if (error) return { error: friendlyShareError(error.message) }; + if (error) { + if (isDuplicateShare(error)) { + const winner = await findShareId('project', 'project_id', project.id); + if (winner.id) return { id: winner.id }; + } + return { error: friendlyShareError(error.message) }; + } return { id }; } diff --git a/supabase/schema.sql b/supabase/schema.sql index 5e0773c..2354c32 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -338,6 +338,25 @@ create index if not exists shares_user_idx on public.shares (user_id); create index if not exists shares_graph_idx on public.shares (graph_id); create index if not exists shares_project_idx on public.shares (project_id); +-- One live link per piece of content. The client looks up an existing share +-- before minting a slug, but two shares created at once would both miss and +-- each insert a row — and revoking the link shown in the UI would leave the +-- other one publicly readable. Collapse any duplicates that predate these +-- indexes (keeping the most recently updated) so they can be created. +delete from public.shares s +using public.shares t +where s.id <> t.id + and s.user_id = t.user_id + and s.kind = t.kind + and s.graph_id is not distinct from t.graph_id + and s.project_id is not distinct from t.project_id + and (s.updated_at, s.id) < (t.updated_at, t.id); + +create unique index if not exists shares_one_per_graph + on public.shares (user_id, graph_id) where kind = 'graph'; +create unique index if not exists shares_one_per_project + on public.shares (user_id, project_id) where kind = 'project'; + alter table public.shares enable row level security; -- Owners can read their own share rows (needed for getShareIdFor* / refresh). @@ -386,6 +405,47 @@ create policy "shares: delete own" on public.shares for delete using (auth.uid() = user_id); +-- Deleting shared content must take its public link with it. The client already +-- prunes these in refreshShares() during sync, but that pass is best-effort and +-- its failures are swallowed, which would leave a "deleted" diagram readable by +-- anyone still holding the slug. Doing it here makes the revoke happen the +-- moment the deletion reaches the server, whichever client sent it. +-- +-- Note this covers directly shared rows only. A graph deleted out of a SHARED +-- PROJECT still needs the client to re-render that project's payload, since the +-- payload is a snapshot the database can't rebuild. +create or replace function public.purge_shares_for_deleted_content() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + if new.deleted and not coalesce(old.deleted, false) then + if tg_table_name = 'graphs' then + delete from public.shares + where user_id = new.user_id and kind = 'graph' and graph_id = new.id; + else + delete from public.shares + where user_id = new.user_id and kind = 'project' and project_id = new.id; + end if; + end if; + return null; +end; +$$; + +revoke execute on function public.purge_shares_for_deleted_content() from public, anon, authenticated; + +drop trigger if exists graphs_purge_shares_on_delete on public.graphs; +create trigger graphs_purge_shares_on_delete + after insert or update of deleted on public.graphs + for each row execute function public.purge_shares_for_deleted_content(); + +drop trigger if exists projects_purge_shares_on_delete on public.projects; +create trigger projects_purge_shares_on_delete + after insert or update of deleted on public.projects + for each row execute function public.purge_shares_for_deleted_content(); + -- ---------------------------------------------------------------------------- -- templates — user's custom component templates (synced) -- ---------------------------------------------------------------------------- diff --git a/vite.config.ts b/vite.config.ts index 691833e..58adcd7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -121,10 +121,10 @@ export default defineConfig(({ mode, command }) => { allowedHosts: ['.devtunnels.ms', '.ngrok-free.app', '.trycloudflare.com'], }, plugins: [react(), devApiPlugin(__dirname)], - define: { - 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), - 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) - }, + // No `define` for GEMINI_API_KEY on purpose: it would inline the server's + // key into the client bundle for anyone to read. The browser talks to + // /api/generate, which holds the key server-side; users on their own key + // supply it at runtime through Settings. resolve: { alias: { '@': path.resolve(__dirname, '.'), From 3e545e19998140abc0d8a7fa79cbf443b959cdc0 Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Mon, 27 Jul 2026 22:01:04 +0300 Subject: [PATCH 03/29] Harden update-supporters workflow: don't persist the push token during npm ci actions/checkout leaves a contents:write token in .git/config, where any dependency install script run by `npm ci` could read it. Check out without persisted credentials and pass the token explicitly on the push instead. --- .github/workflows/update-supporters.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/update-supporters.yml b/.github/workflows/update-supporters.yml index 094f11f..e883ae2 100644 --- a/.github/workflows/update-supporters.yml +++ b/.github/workflows/update-supporters.yml @@ -23,6 +23,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # Don't leave a contents:write token in .git/config while `npm ci` + # runs arbitrary dependency install scripts. The push below passes + # the token explicitly instead. + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: '20' @@ -34,13 +39,15 @@ jobs: SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }} run: node scripts/update-supporters.mjs - name: Commit if the README changed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if [ -n "$(git status --porcelain README.md)" ]; then git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add README.md git commit -m "chore: refresh supporters list" - git push + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}" else echo "No supporter changes to commit." fi From 6d569674909e4c7c90f2bd5741f3d18188b907f4 Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Tue, 28 Jul 2026 11:46:05 +0300 Subject: [PATCH 04/29] Give every account its own local diagrams Local diagrams and projects lived under one set of keys shared by everyone using the browser, with an `econgraph_owner` marker naming who they belonged to. Signing in as a different account deleted them. For Supporters that was survivable (their copy is in the cloud) but for free accounts and signed-out work it was permanent, silent data loss on any shared computer. Each account now gets its own namespace, plus one shared "guest" namespace for work done signed out. Switching accounts swaps which namespace is live and never deletes the other, so signing out and back in returns you to exactly what you left. Signed-out work still follows you into an account, but only when that cannot mix two people's diagrams together: the account must have nothing of its own, and for Supporters only once the first cloud pull has answered whether the account is really empty. If the account already has diagrams, the signed-out work stays where it is and is there again on sign out. That rule is `decideGuestAdoption`, kept as a pure function so it can be tested directly. Guest keeps the original key names, so existing local work needs no migration. Data that belonged to an account (per `econgraph_owner`) is moved into that account's namespace once, on first run under the new scheme. Also closes three ways data could still cross between accounts: - sync results that arrive after an account switch are dropped, instead of importing the previous account's cloud data into whoever is signed in now - sync is withheld until the signed-in account's own data is the data in memory, so a switch can't upload the outgoing account's diagrams - writes are suppressed while a namespace swap is in flight And two editor bugs this made reachable: - the auto-open effect selected a newly created graph unconditionally, even when its own guard discarded it, leaving a selected id that matched nothing - a Supporter's first render counted as "not awaiting the first pull", so an empty store briefly looked real and produced a throwaway blank diagram Verified: 23 store tests and 19 account-flow scenario tests (adoption, segmentation, sign-out/in, two free accounts, late sync after a switch), plus the real migration observed running against a live signed-in profile, which moved 12KB of existing diagrams into the correct namespace with nothing lost. --- App.tsx | 177 ++++++++++++++++++++++++-------------- CHANGELOG.md | 5 ++ services/localStore.ts | 181 +++++++++++++++++++++++++++++++++++++++ services/useCloudSync.ts | 9 +- 4 files changed, 306 insertions(+), 66 deletions(-) create mode 100644 services/localStore.ts diff --git a/App.tsx b/App.tsx index f6042e5..6605411 100644 --- a/App.tsx +++ b/App.tsx @@ -5,6 +5,16 @@ import { getAIProvider } from './services/aiProvider'; import { useAuth } from './services/auth'; import { useCloudSync } from './services/useCloudSync'; import { recordTombstones, clearTombstones, fetchCloudIds } from './services/sync'; +import { + GUEST_SCOPE, + migrateLegacyStore, + readScope, + writeGraphs, + writeProjects, + scopeHasContent, + adoptScope, + decideGuestAdoption, +} from './services/localStore'; import DiagramRenderer from './components/DiagramRenderer'; import LandingPage from './components/LandingPage'; import HomePage from './components/HomePage'; @@ -29,16 +39,13 @@ import { const generateId = () => uuidv4(); +// Diagrams and projects are stored per account (see services/localStore.ts). +// These keys are editor preferences, which are deliberately shared across +// accounts on the same browser: they describe the tool, not anyone's work. const STORAGE_KEYS = { - graphs: 'econgraph_graphs', - projects: 'econgraph_projects', settings: 'econgraph_settings', specialColors: 'econgraph_special_colors', standardColors: 'econgraph_standard_colors', - // Which account the locally-stored graphs/projects belong to. The store is - // global (not per-user), so this lets us detect an account switch on a shared - // browser and avoid attributing one person's diagrams to another. - owner: 'econgraph_owner' }; const DEFAULT_STANDARD_COLORS = [ @@ -167,14 +174,28 @@ export default function App() { const { showTooltip: showSendTooltip, hideTooltip: hideSendTooltip, TooltipPortal: SendTooltipPortal } = usePortalTooltip({ delay: 400, placement: 'top' }); // --- Cloud (accounts + sync are Supporter features; app is fully usable without) --- - const { configured: cloudConfigured, user, isPro } = useAuth(); + const { configured: cloudConfigured, loading: authLoading, user, isPro } = useAuth(); // Live refs so applyRemote (a stable, dep-free callback) can see the graph // currently open in the editor without being re-created on every edit. const activeGraphIdRef = useRef(null); const currentDiagramRef = useRef(INITIAL_DIAGRAM); - const applyRemote = useCallback((remoteGraphs: Graph[], remoteProjects: Project[]) => { + // Which account's local data is live. `null` while the session is still being + // restored, so we don't briefly load guest data for someone who is signed in. + const storeScope = authLoading ? null : (user?.id ?? GUEST_SCOPE); + const [loadedScope, setLoadedScope] = useState(null); + const loadedScopeRef = useRef(null); + loadedScopeRef.current = loadedScope; + // True between signing in and deciding whether signed-out work joins this + // account. The editor holds off creating a blank diagram until it resolves. + const [pendingGuestAdoption, setPendingGuestAdoption] = useState(false); + + const applyRemote = useCallback((remoteGraphs: Graph[], remoteProjects: Project[], forUserId: string) => { + // A sync that lands after the account changed is carrying the previous + // account's cloud data. Dropping it keeps that data out of this account + // (and off this account's next upload). + if (loadedScopeRef.current !== forUserId) return; setGraphs(remoteGraphs); setProjects(remoteProjects); // If the graph open in the editor was changed by this pull (e.g. edited on @@ -197,7 +218,10 @@ export default function App() { }, []); const { syncState, syncNow } = useCloudSync({ - userId: user && isPro ? user.id : null, + // Withhold the account until its own local data is the data in memory. + // Syncing during a switch, while the previous account's diagrams are still + // loaded, would upload them into this account. + userId: user && isPro && loadedScope === user.id ? user.id : null, hasInitialized, graphs, projects, @@ -207,28 +231,26 @@ export default function App() { // A signed-in Supporter's local store can be empty simply because the first // cloud pull hasn't landed yet, used below to avoid creating (and syncing // up) a throwaway blank graph before we've heard whether the cloud has data. + // 'disabled' counts too: for a Supporter it means the sync loop hasn't picked + // this account up yet, which is still "before the first pull". Without it + // there's a render where the store looks empty and the editor would create a + // blank diagram (and upload it) moments before the real data arrives. const awaitingFirstPull = cloudConfigured && !!user && isPro && syncState.lastSyncedAt === null && - (syncState.status === 'idle' || syncState.status === 'syncing'); + (syncState.status === 'idle' || syncState.status === 'syncing' || syncState.status === 'disabled'); - // --- Load from localStorage on mount --- + // --- Load shared editor preferences on mount --- + // Diagrams and projects are NOT loaded here: they belong to whichever account + // is signed in, which isn't known until the session has been restored. See + // the scope effect below. useEffect(() => { + migrateLegacyStore(); try { - const savedGraphs = localStorage.getItem(STORAGE_KEYS.graphs); - const savedProjects = localStorage.getItem(STORAGE_KEYS.projects); const savedSettings = localStorage.getItem(STORAGE_KEYS.settings); const savedSpecial = localStorage.getItem(STORAGE_KEYS.specialColors); const savedStandard = localStorage.getItem(STORAGE_KEYS.standardColors); - if (savedGraphs) { - const parsed = JSON.parse(savedGraphs) as Graph[]; - setGraphs(parsed); - } - if (savedProjects) { - const parsed = JSON.parse(savedProjects) as Project[]; - setProjects(parsed); - } if (savedSettings) { const parsed = JSON.parse(savedSettings); setSettings(s => ({ ...s, ...parsed })); @@ -246,39 +268,67 @@ export default function App() { } } } catch (e) { - console.error('Failed to load data from localStorage:', e); + console.error('Failed to load preferences from localStorage:', e); } - setHasInitialized(true); }, []); - // --- Guard against cross-account data bleed on a shared browser --- - // The local store is global (not per-user). When a DIFFERENT account signs in, - // the previous user's diagrams must not be treated as (and synced up into) the - // new account. Anonymous local work (no recorded owner) is still migrated to - // the first account that signs in; the same user signing back in keeps theirs. + // --- Per-account local data --- + // Everyone who uses this browser gets their own namespace: one per signed-in + // account, plus a shared "guest" one for work done signed out. Switching + // accounts swaps which namespace is live, and never deletes the other one. useEffect(() => { - if (!hasInitialized) return; - const uid = user?.id ?? null; - if (!uid) return; // signed out: leave local data + owner untouched - let owner: string | null = null; - try { owner = localStorage.getItem(STORAGE_KEYS.owner); } catch { /* ignore */ } - if (owner && owner !== uid) { - // Someone else's local data, clear it so it isn't attributed to this - // account. Their data is safe in their own cloud (if a Supporter). - setGraphs([]); - setProjects([]); - setActiveGraphId(null); - // Also blank the canvas and its undo stack. Clearing the collections - // alone leaves the previous account's open diagram on screen until the - // first cloud pull lands, which is exactly what this guard is for. - const blank = { ...EMPTY_DIAGRAM }; - setCurrentDiagram(blank); - setHistory([blank]); - historyRef.current = [blank]; - setHistoryIndex(0); + if (storeScope === null || storeScope === loadedScope) return; + const stored = readScope(storeScope); + setGraphs(stored.graphs); + setProjects(stored.projects); + // Signing in with nothing of your own, over work done signed out, is the + // one case where the two might be joined. Flag it here so the editor waits + // for that decision instead of creating a blank diagram in the meantime. + setPendingGuestAdoption( + storeScope !== GUEST_SCOPE + && stored.graphs.length === 0 + && stored.projects.length === 0 + && scopeHasContent(GUEST_SCOPE) + ); + // Close whatever was open and blank the canvas: it belongs to the namespace + // we're leaving. The auto-open effect below picks this account's most + // recent diagram once its data is in place. + setActiveGraphId(null); + const blank = { ...EMPTY_DIAGRAM }; + setCurrentDiagram(blank); + setHistory([blank]); + historyRef.current = [blank]; + setHistoryIndex(0); + setLoadedScope(storeScope); + setHasInitialized(true); + }, [storeScope, loadedScope]); + + // --- Hand guest work to the account that signs in --- + // Work done signed out should follow you into your account, but only when + // doing so can't mix it into diagrams that are already there. So we adopt it + // only if this account has nothing of its own, and for Supporters only once + // the first cloud pull has told us whether the account is really empty. + // Otherwise the guest namespace is left untouched, and signing out returns to + // it intact. + useEffect(() => { + if (storeScope === null) return; + const decision = decideGuestAdoption({ + pending: pendingGuestAdoption, + scopeReady: loadedScope === storeScope, + awaitingFirstPull, + accountHasContent: graphs.length > 0 || projects.length > 0, + }); + if (decision === 'wait') return; + + if (decision === 'adopt') { + const adopted = adoptScope(GUEST_SCOPE, storeScope); + setGraphs(adopted.graphs); + setProjects(adopted.projects); } - try { localStorage.setItem(STORAGE_KEYS.owner, uid); } catch { /* ignore */ } - }, [user?.id, hasInitialized]); + // 'keep-separate': the account brought its own diagrams (pulled from the + // cloud), so the signed-out work stays where it is, ready for next time. + setPendingGuestAdoption(false); + }, [pendingGuestAdoption, storeScope, loadedScope, awaitingFirstPull, graphs.length, projects.length]); // Keep live refs in sync for dep-free callbacks (see applyRemote). useEffect(() => { activeGraphIdRef.current = activeGraphId; }, [activeGraphId]); @@ -304,6 +354,10 @@ export default function App() { // Wait for the first cloud pull before assuming a Supporter has no graphs //, otherwise we'd create a blank one and sync it up as clutter. if (awaitingFirstPull) return; + // Likewise, don't create one while work done signed out is about to be + // handed to this account: that would leave a stray blank diagram beside it + // (and select it, since the graph below is chosen unconditionally). + if (pendingGuestAdoption) return; // Create new graph if none exist const newGraph: Graph = { id: generateId(), @@ -326,26 +380,21 @@ export default function App() { historyRef.current = [newGraph.diagramData]; setHistoryIndex(0); } - }, [view, hasInitialized, activeGraphId, graphs.length, awaitingFirstPull]); // Use graphs.length instead of graphs to avoid re-trigger on content changes + }, [view, hasInitialized, activeGraphId, graphs.length, awaitingFirstPull, pendingGuestAdoption]); // Use graphs.length instead of graphs to avoid re-trigger on content changes // --- Save to localStorage when data changes (only after initial load) --- + // Only write once the namespace in memory is the one we last loaded. During an + // account switch those differ for a render, and writing then would save the + // outgoing account's diagrams over the incoming account's. useEffect(() => { - if (!hasInitialized) return; - try { - localStorage.setItem(STORAGE_KEYS.graphs, JSON.stringify(graphs)); - } catch (e) { - console.error('Failed to save graphs:', e); - } - }, [graphs, hasInitialized]); + if (!hasInitialized || loadedScope === null || loadedScope !== storeScope) return; + writeGraphs(loadedScope, graphs); + }, [graphs, hasInitialized, loadedScope, storeScope]); useEffect(() => { - if (!hasInitialized) return; - try { - localStorage.setItem(STORAGE_KEYS.projects, JSON.stringify(projects)); - } catch (e) { - console.error('Failed to save projects:', e); - } - }, [projects, hasInitialized]); + if (!hasInitialized || loadedScope === null || loadedScope !== storeScope) return; + writeProjects(loadedScope, projects); + }, [projects, hasInitialized, loadedScope, storeScope]); useEffect(() => { if (!hasInitialized) return; diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba0c66..db3b4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pricing page (`/pricing`) and fact-checked comparison page (`/compare`) - 12 prerendered SEO landing pages (`/diagrams/*`) with IB-specific content, generated at build time along with the sitemap +- **Per-account local storage**: each account that signs in on a browser gets + its own local diagrams, alongside a shared one for work done signed out. + Switching accounts on a shared computer no longer erases anyone's work. + Signed-out work is handed to the account you sign into only when that account + has no diagrams of its own, so two people's diagrams are never merged - Supporter recognition: opt-in name listing in the README - Backend setup guide (`docs/BACKEND_SETUP.md`): all cloud features degrade gracefully when unconfigured, so forks stay zero-config diff --git a/services/localStore.ts b/services/localStore.ts new file mode 100644 index 0000000..c283690 --- /dev/null +++ b/services/localStore.ts @@ -0,0 +1,181 @@ +import { Graph, Project } from '../types'; + +/** + * Per-account local storage for diagrams and projects. + * + * The browser's local store is shared by everyone who uses the browser, but the + * app's content is not: two people signing into the same browser must never see + * (or overwrite) each other's diagrams. Every account therefore gets its own + * namespace, keyed by user id, plus one shared "guest" namespace for work done + * while signed out. + * + * Nothing is ever deleted on an account switch. Signing out and back in returns + * you to exactly what you left. + * + * Note that guest work is genuinely shared: two people using the same browser + * without signing in are indistinguishable, so they see the same diagrams. + * That is unavoidable, and signing in is what separates them. + */ + +/** Namespace for work done while signed out. */ +export const GUEST_SCOPE = 'guest'; + +/** A storage namespace: a user id, or GUEST_SCOPE. */ +export type StoreScope = string; + +/** + * Guest keeps the original unprefixed names so that existing local work is + * still there after this change ships, with no migration needed. + */ +const BASE_KEYS = { + graphs: 'econgraph_graphs', + projects: 'econgraph_projects', +} as const; + +type Collection = keyof typeof BASE_KEYS; + +/** Pre-namespacing key recording which account the shared store belonged to. */ +const LEGACY_OWNER_KEY = 'econgraph_owner'; +const VERSION_KEY = 'econgraph_store_version'; +const CURRENT_VERSION = '2'; + +function keyFor(collection: Collection, scope: StoreScope): string { + const base = BASE_KEYS[collection]; + return scope === GUEST_SCOPE ? base : `${base}__u_${scope}`; +} + +function readRaw(collection: Collection, scope: StoreScope): string | null { + try { + return localStorage.getItem(keyFor(collection, scope)); + } catch { + return null; + } +} + +function writeRaw(collection: Collection, scope: StoreScope, raw: string | null): void { + try { + const key = keyFor(collection, scope); + if (raw === null) localStorage.removeItem(key); + else localStorage.setItem(key, raw); + } catch (e) { + // Quota is the realistic failure here: several accounts' diagrams now + // coexist in one browser. Surface it rather than losing writes silently. + console.error(`Failed to write ${collection} for scope ${scope}:`, e); + } +} + +function parseArray(raw: string | null): T[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +/** + * One-time move from the old shared store to per-account namespaces. + * + * Before this, everyone's diagrams shared one set of keys and `econgraph_owner` + * recorded who they belonged to. If an account owned them, they move into that + * account's namespace so signing in still finds them. If nothing owned them, + * they were anonymous and already live where guest work belongs. + * + * Safe to call on every start: it does nothing once the version marker is set. + */ +export function migrateLegacyStore(): void { + let version: string | null = null; + try { + version = localStorage.getItem(VERSION_KEY); + } catch { + return; // storage unavailable (private mode with storage disabled) + } + if (version === CURRENT_VERSION) return; + + let owner: string | null = null; + try { + owner = localStorage.getItem(LEGACY_OWNER_KEY); + } catch { /* ignore */ } + + if (owner && owner !== GUEST_SCOPE) { + for (const collection of Object.keys(BASE_KEYS) as Collection[]) { + const raw = readRaw(collection, GUEST_SCOPE); + // Don't clobber an existing namespace if this somehow runs twice. + if (raw !== null && readRaw(collection, owner) === null) { + writeRaw(collection, owner, raw); + writeRaw(collection, GUEST_SCOPE, null); + } + } + try { localStorage.removeItem(LEGACY_OWNER_KEY); } catch { /* ignore */ } + } + + try { localStorage.setItem(VERSION_KEY, CURRENT_VERSION); } catch { /* ignore */ } +} + +/** Read one account's (or the guest's) stored diagrams and projects. */ +export function readScope(scope: StoreScope): { graphs: Graph[]; projects: Project[] } { + return { + graphs: parseArray(readRaw('graphs', scope)), + projects: parseArray(readRaw('projects', scope)), + }; +} + +export function writeGraphs(scope: StoreScope, graphs: Graph[]): void { + writeRaw('graphs', scope, JSON.stringify(graphs)); +} + +export function writeProjects(scope: StoreScope, projects: Project[]): void { + writeRaw('projects', scope, JSON.stringify(projects)); +} + +/** Whether a namespace holds anything worth keeping. */ +export function scopeHasContent(scope: StoreScope): boolean { + const { graphs, projects } = readScope(scope); + return graphs.length > 0 || projects.length > 0; +} + +/** + * Hand a namespace's contents over to another one, emptying the source. + * + * Used when a signed-in account takes ownership of work done while signed out. + * The caller must have established that the destination is empty: this + * overwrites rather than merges, precisely so two people's diagrams are never + * silently mixed together. + */ +/** + * What to do with work done signed out, once someone signs in. + * + * - `wait` nothing to decide yet, or we can't tell whether the + * account is empty until its first cloud pull lands. + * - `adopt` the account has nothing of its own, so the signed-out work + * becomes theirs. + * - `keep-separate` the account already has diagrams. Never merge the two: + * the signed-out work stays where it is and is still there + * when they sign out again. + */ +export type AdoptionDecision = 'wait' | 'adopt' | 'keep-separate'; + +export function decideGuestAdoption(input: { + /** Signed in over guest work, with no diagrams of their own at load time. */ + pending: boolean; + /** The namespace in memory is the one we last loaded (no swap in flight). */ + scopeReady: boolean; + /** A Supporter whose first cloud pull hasn't landed yet. */ + awaitingFirstPull: boolean; + /** Whether the account has any diagrams or projects right now. */ + accountHasContent: boolean; +}): AdoptionDecision { + if (!input.pending || !input.scopeReady) return 'wait'; + if (input.awaitingFirstPull) return 'wait'; + return input.accountHasContent ? 'keep-separate' : 'adopt'; +} + +export function adoptScope(from: StoreScope, to: StoreScope): { graphs: Graph[]; projects: Project[] } { + const moved = readScope(from); + writeGraphs(to, moved.graphs); + writeProjects(to, moved.projects); + writeRaw('graphs', from, null); + writeRaw('projects', from, null); + return moved; +} diff --git a/services/useCloudSync.ts b/services/useCloudSync.ts index 3e77510..7b7b647 100644 --- a/services/useCloudSync.ts +++ b/services/useCloudSync.ts @@ -19,7 +19,12 @@ interface UseCloudSyncOptions { hasInitialized: boolean; graphs: Graph[]; projects: Project[]; - applyRemote: (graphs: Graph[], projects: Project[]) => void; + /** + * Hand merged cloud state back to the app. `userId` identifies the account + * the sync ran for, so a result that arrives after an account switch can be + * discarded rather than imported into whoever is signed in now. + */ + applyRemote: (graphs: Graph[], projects: Project[], userId: string) => void; } /** @@ -71,7 +76,7 @@ export function useCloudSync({ userId, hasInitialized, graphs, projects, applyRe const localMoved = graphsRef.current !== startGraphs || projectsRef.current !== startProjects; if (outcome.changedLocal && !localMoved) { - applyRemoteRef.current(outcome.graphs, outcome.projects); + applyRemoteRef.current(outcome.graphs, outcome.projects, uid); } else if (outcome.changedLocal && localMoved) { // Local state advanced while we were syncing — run again rather // than applying a stale merge. From d89e081b5ca52fd530c22cadf6ba690607962498 Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Tue, 28 Jul 2026 12:16:29 +0300 Subject: [PATCH 05/29] Store diagrams in IndexedDB instead of localStorage localStorage caps an origin at roughly 5MB, and every account on the browser now shares it. A diagram is ~6KB, but each AI chat turn stores its own full diagram snapshot, so a graph with ten turns is closer to 66KB: the real ceiling was around 75-100 diagrams, not the several hundred a raw count suggests. That same 5MB also holds the Supabase auth token, so filling it could break signing in, not just saving. IndexedDB reports a 10GB quota on the same machine. Diagrams and projects move there; editor preferences stay in localStorage, since they are tiny and shared across accounts by design. The store keeps its shape, so this is contained to localStore.ts plus the call sites that now await. Reads funnel through a single ready() promise, so callers never have to sequence initialisation themselves. Writes are serialised per key so two rapid saves cannot resolve out of order and leave the older array on disk. localStorage remains the fallback when IndexedDB cannot be opened, with a timeout so a database blocked by another tab degrades instead of hanging the app. Migration runs once on first load and is resumable: any namespace already in IndexedDB is left alone, so a partial run can simply be repeated. Both earlier layouts are handled, including a browser that never saw the per-account version. Each key is removed from localStorage as it moves, which is what frees the 5MB. Also asks for persistent storage so the browser does not evict saved diagrams under disk pressure. It is advisory: Chrome grants it on engagement signals and currently declines on localhost. Not compressing. gzip measures 4.5x on real diagram JSON, but IndexedDB stores structured clones rather than UTF-16 strings, so the data is already smaller than it was, and against a 10GB quota the saving buys nothing but CPU on every read. It was only worth considering to stretch the 5MB cap. Verified: 24 scenario tests on the fallback path, and in a real browser the migration moved all three existing namespaces into IndexedDB with identical contents, left zero diagram keys in localStorage, and survived a reload with new writes landing correctly. --- App.tsx | 86 +++++++----- CHANGELOG.md | 4 +- services/localStore.ts | 307 ++++++++++++++++++++++++++++++++++------- 3 files changed, 310 insertions(+), 87 deletions(-) diff --git a/App.tsx b/App.tsx index 6605411..acb2f82 100644 --- a/App.tsx +++ b/App.tsx @@ -7,7 +7,8 @@ import { useCloudSync } from './services/useCloudSync'; import { recordTombstones, clearTombstones, fetchCloudIds } from './services/sync'; import { GUEST_SCOPE, - migrateLegacyStore, + initLocalStore, + requestPersistentStorage, readScope, writeGraphs, writeProjects, @@ -245,7 +246,11 @@ export default function App() { // is signed in, which isn't known until the session has been restored. See // the scope effect below. useEffect(() => { - migrateLegacyStore(); + // Open the diagram store (and migrate into it) early. Reads wait on this + // internally, so this is just a head start, not a prerequisite. + void initLocalStore(); + // Ask the browser not to evict saved diagrams when disk runs low. + void requestPersistentStorage(); try { const savedSettings = localStorage.getItem(STORAGE_KEYS.settings); const savedSpecial = localStorage.getItem(STORAGE_KEYS.specialColors); @@ -278,29 +283,37 @@ export default function App() { // accounts swaps which namespace is live, and never deletes the other one. useEffect(() => { if (storeScope === null || storeScope === loadedScope) return; - const stored = readScope(storeScope); - setGraphs(stored.graphs); - setProjects(stored.projects); - // Signing in with nothing of your own, over work done signed out, is the - // one case where the two might be joined. Flag it here so the editor waits - // for that decision instead of creating a blank diagram in the meantime. - setPendingGuestAdoption( - storeScope !== GUEST_SCOPE - && stored.graphs.length === 0 - && stored.projects.length === 0 - && scopeHasContent(GUEST_SCOPE) - ); - // Close whatever was open and blank the canvas: it belongs to the namespace - // we're leaving. The auto-open effect below picks this account's most - // recent diagram once its data is in place. - setActiveGraphId(null); - const blank = { ...EMPTY_DIAGRAM }; - setCurrentDiagram(blank); - setHistory([blank]); - historyRef.current = [blank]; - setHistoryIndex(0); - setLoadedScope(storeScope); - setHasInitialized(true); + let cancelled = false; + void (async () => { + const stored = await readScope(storeScope); + // Signing in with nothing of your own, over work done signed out, is the + // one case where the two might be joined. Resolve it here so the editor + // waits for that decision instead of creating a blank diagram meanwhile. + const guestPending = + storeScope !== GUEST_SCOPE + && stored.graphs.length === 0 + && stored.projects.length === 0 + && await scopeHasContent(GUEST_SCOPE); + // The account may have changed again while this was loading; whichever + // effect run matches the live namespace is the one allowed to apply. + if (cancelled) return; + + setGraphs(stored.graphs); + setProjects(stored.projects); + setPendingGuestAdoption(guestPending); + // Close whatever was open and blank the canvas: it belongs to the + // namespace we're leaving. The auto-open effect below picks this + // account's most recent diagram once its data is in place. + setActiveGraphId(null); + const blank = { ...EMPTY_DIAGRAM }; + setCurrentDiagram(blank); + setHistory([blank]); + historyRef.current = [blank]; + setHistoryIndex(0); + setLoadedScope(storeScope); + setHasInitialized(true); + })(); + return () => { cancelled = true; }; }, [storeScope, loadedScope]); // --- Hand guest work to the account that signs in --- @@ -320,14 +333,21 @@ export default function App() { }); if (decision === 'wait') return; - if (decision === 'adopt') { - const adopted = adoptScope(GUEST_SCOPE, storeScope); - setGraphs(adopted.graphs); - setProjects(adopted.projects); - } + // Settle the decision before awaiting anything, so this can't run twice and + // hand the same work over twice. + setPendingGuestAdoption(false); // 'keep-separate': the account brought its own diagrams (pulled from the // cloud), so the signed-out work stays where it is, ready for next time. - setPendingGuestAdoption(false); + if (decision !== 'adopt') return; + + let cancelled = false; + void (async () => { + const adopted = await adoptScope(GUEST_SCOPE, storeScope); + if (cancelled) return; + setGraphs(adopted.graphs); + setProjects(adopted.projects); + })(); + return () => { cancelled = true; }; }, [pendingGuestAdoption, storeScope, loadedScope, awaitingFirstPull, graphs.length, projects.length]); // Keep live refs in sync for dep-free callbacks (see applyRemote). @@ -388,12 +408,12 @@ export default function App() { // outgoing account's diagrams over the incoming account's. useEffect(() => { if (!hasInitialized || loadedScope === null || loadedScope !== storeScope) return; - writeGraphs(loadedScope, graphs); + void writeGraphs(loadedScope, graphs); }, [graphs, hasInitialized, loadedScope, storeScope]); useEffect(() => { if (!hasInitialized || loadedScope === null || loadedScope !== storeScope) return; - writeProjects(loadedScope, projects); + void writeProjects(loadedScope, projects); }, [projects, hasInitialized, loadedScope, storeScope]); useEffect(() => { diff --git a/CHANGELOG.md b/CHANGELOG.md index db3b4e8..96a2ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its own local diagrams, alongside a shared one for work done signed out. Switching accounts on a shared computer no longer erases anyone's work. Signed-out work is handed to the account you sign into only when that account - has no diagrams of its own, so two people's diagrams are never merged + has no diagrams of its own, so two people's diagrams are never merged. + Diagrams now live in IndexedDB (gigabytes) rather than localStorage (~5MB + shared with the auth token), migrated automatically on first load - Supporter recognition: opt-in name listing in the README - Backend setup guide (`docs/BACKEND_SETUP.md`): all cloud features degrade gracefully when unconfigured, so forks stay zero-config diff --git a/services/localStore.ts b/services/localStore.ts index c283690..d14df1a 100644 --- a/services/localStore.ts +++ b/services/localStore.ts @@ -15,6 +15,13 @@ import { Graph, Project } from '../types'; * Note that guest work is genuinely shared: two people using the same browser * without signing in are indistinguishable, so they see the same diagrams. * That is unavoidable, and signing in is what separates them. + * + * Diagrams live in IndexedDB rather than localStorage. localStorage caps an + * origin at roughly 5MB, which several accounts' diagrams share (and a diagram + * carries a full snapshot per AI chat turn, so they are not small). Worse, that + * same 5MB holds the auth token, so filling it could break signing in. + * IndexedDB is measured in gigabytes. localStorage remains the fallback for + * browsers where IndexedDB can't be opened. */ /** Namespace for work done while signed out. */ @@ -24,8 +31,8 @@ export const GUEST_SCOPE = 'guest'; export type StoreScope = string; /** - * Guest keeps the original unprefixed names so that existing local work is - * still there after this change ships, with no migration needed. + * Guest keeps the original unprefixed names so that local work predating any of + * this is still found and carried forward. */ const BASE_KEYS = { graphs: 'econgraph_graphs', @@ -33,33 +40,47 @@ const BASE_KEYS = { } as const; type Collection = keyof typeof BASE_KEYS; +const COLLECTIONS = Object.keys(BASE_KEYS) as Collection[]; /** Pre-namespacing key recording which account the shared store belonged to. */ const LEGACY_OWNER_KEY = 'econgraph_owner'; const VERSION_KEY = 'econgraph_store_version'; -const CURRENT_VERSION = '2'; +const VERSION_NAMESPACED = '2'; // per-account, still in localStorage +const VERSION_INDEXEDDB = '3'; // per-account, moved to IndexedDB + +const DB_NAME = 'econgraph'; +const DB_VERSION = 1; +const DB_STORE = 'scopes'; +/** Give up and fall back rather than hanging the app behind a stuck open(). */ +const DB_OPEN_TIMEOUT_MS = 4000; -function keyFor(collection: Collection, scope: StoreScope): string { +function localKey(collection: Collection, scope: StoreScope): string { const base = BASE_KEYS[collection]; return scope === GUEST_SCOPE ? base : `${base}__u_${scope}`; } -function readRaw(collection: Collection, scope: StoreScope): string | null { +function dbKey(collection: Collection, scope: StoreScope): string { + return `${scope}::${collection}`; +} + +// --------------------------------------------------------------------------- +// localStorage backend (also the source for the one-time move into IndexedDB) +// --------------------------------------------------------------------------- + +function lsGet(collection: Collection, scope: StoreScope): string | null { try { - return localStorage.getItem(keyFor(collection, scope)); + return localStorage.getItem(localKey(collection, scope)); } catch { return null; } } -function writeRaw(collection: Collection, scope: StoreScope, raw: string | null): void { +function lsSet(collection: Collection, scope: StoreScope, raw: string | null): void { try { - const key = keyFor(collection, scope); + const key = localKey(collection, scope); if (raw === null) localStorage.removeItem(key); else localStorage.setItem(key, raw); } catch (e) { - // Quota is the realistic failure here: several accounts' diagrams now - // coexist in one browser. Surface it rather than losing writes silently. console.error(`Failed to write ${collection} for scope ${scope}:`, e); } } @@ -74,75 +95,242 @@ function parseArray(raw: string | null): T[] { } } +// --------------------------------------------------------------------------- +// IndexedDB backend +// --------------------------------------------------------------------------- + +function openDb(): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (db: IDBDatabase | null) => { + if (settled) return; + settled = true; + resolve(db); + }; + try { + if (typeof indexedDB === 'undefined') return done(null); + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(DB_STORE)) db.createObjectStore(DB_STORE); + }; + req.onsuccess = () => done(req.result); + req.onerror = () => done(null); + // Another tab is mid-upgrade and holding the database. + req.onblocked = () => done(null); + setTimeout(() => done(null), DB_OPEN_TIMEOUT_MS); + } catch { + done(null); + } + }); +} + +function idbRequest(db: IDBDatabase, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise { + return new Promise((resolve) => { + try { + const tx = db.transaction(DB_STORE, mode); + const req = run(tx.objectStore(DB_STORE)); + req.onsuccess = () => resolve(req.result as T); + req.onerror = () => resolve(null); + tx.onabort = () => resolve(null); + } catch (e) { + console.error('IndexedDB operation failed:', e); + resolve(null); + } + }); +} + +const idbGet = (db: IDBDatabase, key: string) => idbRequest(db, 'readonly', (s) => s.get(key)); +const idbPut = (db: IDBDatabase, key: string, value: unknown) => idbRequest(db, 'readwrite', (s) => s.put(value, key)); +const idbDelete = (db: IDBDatabase, key: string) => idbRequest(db, 'readwrite', (s) => s.delete(key)); + +// --------------------------------------------------------------------------- +// Initialisation and migration +// --------------------------------------------------------------------------- + +let db: IDBDatabase | null = null; +let readyPromise: Promise | null = null; + +function readVersion(): string | null { + try { + return localStorage.getItem(VERSION_KEY); + } catch { + return null; + } +} + +function writeVersion(version: string): void { + try { + localStorage.setItem(VERSION_KEY, version); + } catch { /* ignore */ } +} + /** - * One-time move from the old shared store to per-account namespaces. + * Split the old shared store into per-account namespaces (still localStorage). * * Before this, everyone's diagrams shared one set of keys and `econgraph_owner` * recorded who they belonged to. If an account owned them, they move into that * account's namespace so signing in still finds them. If nothing owned them, * they were anonymous and already live where guest work belongs. - * - * Safe to call on every start: it does nothing once the version marker is set. */ -export function migrateLegacyStore(): void { - let version: string | null = null; - try { - version = localStorage.getItem(VERSION_KEY); - } catch { - return; // storage unavailable (private mode with storage disabled) - } - if (version === CURRENT_VERSION) return; - +function migrateToNamespaces(): void { let owner: string | null = null; try { owner = localStorage.getItem(LEGACY_OWNER_KEY); } catch { /* ignore */ } if (owner && owner !== GUEST_SCOPE) { - for (const collection of Object.keys(BASE_KEYS) as Collection[]) { - const raw = readRaw(collection, GUEST_SCOPE); + for (const collection of COLLECTIONS) { + const raw = lsGet(collection, GUEST_SCOPE); // Don't clobber an existing namespace if this somehow runs twice. - if (raw !== null && readRaw(collection, owner) === null) { - writeRaw(collection, owner, raw); - writeRaw(collection, GUEST_SCOPE, null); + if (raw !== null && lsGet(collection, owner) === null) { + lsSet(collection, owner, raw); + lsSet(collection, GUEST_SCOPE, null); } } try { localStorage.removeItem(LEGACY_OWNER_KEY); } catch { /* ignore */ } } + writeVersion(VERSION_NAMESPACED); +} - try { localStorage.setItem(VERSION_KEY, CURRENT_VERSION); } catch { /* ignore */ } +/** + * Move every namespace out of localStorage and into IndexedDB, freeing the + * origin's 5MB budget. Scans for any `econgraph_graphs*` / `econgraph_projects*` + * key so it catches guest and every account in one pass. + */ +async function migrateToIndexedDb(database: IDBDatabase): Promise { + let keys: string[] = []; + try { + keys = Object.keys(localStorage); + } catch { + return; + } + + for (const collection of COLLECTIONS) { + const base = BASE_KEYS[collection]; + for (const key of keys) { + if (key !== base && !key.startsWith(`${base}__u_`)) continue; + const scope = key === base ? GUEST_SCOPE : key.slice(`${base}__u_`.length); + let raw: string | null = null; + try { raw = localStorage.getItem(key); } catch { continue; } + if (raw === null) continue; + + const existing = await idbGet(database, dbKey(collection, scope)); + // Only seed a namespace IndexedDB doesn't already know about, so a + // partially completed run can be repeated safely. + if (!Array.isArray(existing)) { + await idbPut(database, dbKey(collection, scope), parseArray(raw)); + } + try { localStorage.removeItem(key); } catch { /* ignore */ } + } + } + writeVersion(VERSION_INDEXEDDB); +} + +async function init(): Promise { + if (readVersion() !== VERSION_NAMESPACED && readVersion() !== VERSION_INDEXEDDB) { + migrateToNamespaces(); + } + db = await openDb(); + if (db && readVersion() !== VERSION_INDEXEDDB) { + await migrateToIndexedDb(db); + } +} + +/** + * Every public call funnels through this, so callers never have to think about + * ordering: a read issued before initialisation finishes simply waits for it. + */ +function ready(): Promise { + if (!readyPromise) { + readyPromise = init().catch((e) => { + // Fall back to localStorage rather than leaving the app unable to + // load anything at all. + console.error('Local store initialisation failed, using localStorage:', e); + db = null; + }); + } + return readyPromise; +} + +/** Start opening the database. Optional: any read awaits this anyway. */ +export function initLocalStore(): Promise { + return ready(); +} + +/** + * Ask the browser not to evict this origin's data when disk runs low. Purely + * advisory, and unrelated to the quota itself. + */ +export async function requestPersistentStorage(): Promise { + try { + if (!navigator.storage?.persist) return false; + if (await navigator.storage.persisted()) return true; + return await navigator.storage.persist(); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Reads and writes +// --------------------------------------------------------------------------- + +async function readCollection(collection: Collection, scope: StoreScope): Promise { + await ready(); + if (db) { + const value = await idbGet(db, dbKey(collection, scope)); + return Array.isArray(value) ? value : []; + } + return parseArray(lsGet(collection, scope)); +} + +// Serialise writes per key: two rapid saves resolving out of order would +// otherwise leave the older array on disk. +const writeQueues = new Map>(); + +function enqueueWrite(key: string, op: () => Promise): Promise { + const previous = writeQueues.get(key) ?? Promise.resolve(); + const next = previous.then(op, op).catch((e) => { + console.error(`Failed to save ${key}:`, e); + }); + writeQueues.set(key, next); + return next; +} + +async function writeCollection(collection: Collection, scope: StoreScope, items: T[]): Promise { + await ready(); + const key = dbKey(collection, scope); + await enqueueWrite(key, async () => { + if (db) return idbPut(db, key, items); + lsSet(collection, scope, JSON.stringify(items)); + return undefined; + }); } /** Read one account's (or the guest's) stored diagrams and projects. */ -export function readScope(scope: StoreScope): { graphs: Graph[]; projects: Project[] } { - return { - graphs: parseArray(readRaw('graphs', scope)), - projects: parseArray(readRaw('projects', scope)), - }; +export async function readScope(scope: StoreScope): Promise<{ graphs: Graph[]; projects: Project[] }> { + const [graphs, projects] = await Promise.all([ + readCollection('graphs', scope), + readCollection('projects', scope), + ]); + return { graphs, projects }; } -export function writeGraphs(scope: StoreScope, graphs: Graph[]): void { - writeRaw('graphs', scope, JSON.stringify(graphs)); +export function writeGraphs(scope: StoreScope, graphs: Graph[]): Promise { + return writeCollection('graphs', scope, graphs); } -export function writeProjects(scope: StoreScope, projects: Project[]): void { - writeRaw('projects', scope, JSON.stringify(projects)); +export function writeProjects(scope: StoreScope, projects: Project[]): Promise { + return writeCollection('projects', scope, projects); } /** Whether a namespace holds anything worth keeping. */ -export function scopeHasContent(scope: StoreScope): boolean { - const { graphs, projects } = readScope(scope); +export async function scopeHasContent(scope: StoreScope): Promise { + const { graphs, projects } = await readScope(scope); return graphs.length > 0 || projects.length > 0; } -/** - * Hand a namespace's contents over to another one, emptying the source. - * - * Used when a signed-in account takes ownership of work done while signed out. - * The caller must have established that the destination is empty: this - * overwrites rather than merges, precisely so two people's diagrams are never - * silently mixed together. - */ /** * What to do with work done signed out, once someone signs in. * @@ -171,11 +359,24 @@ export function decideGuestAdoption(input: { return input.accountHasContent ? 'keep-separate' : 'adopt'; } -export function adoptScope(from: StoreScope, to: StoreScope): { graphs: Graph[]; projects: Project[] } { - const moved = readScope(from); - writeGraphs(to, moved.graphs); - writeProjects(to, moved.projects); - writeRaw('graphs', from, null); - writeRaw('projects', from, null); +/** + * Hand a namespace's contents over to another one, emptying the source. + * + * Used when a signed-in account takes ownership of work done while signed out. + * The caller must have established that the destination is empty: this + * overwrites rather than merges, precisely so two people's diagrams are never + * silently mixed together. + */ +export async function adoptScope(from: StoreScope, to: StoreScope): Promise<{ graphs: Graph[]; projects: Project[] }> { + const moved = await readScope(from); + await Promise.all([writeGraphs(to, moved.graphs), writeProjects(to, moved.projects)]); + await Promise.all([writeGraphs(from, []), writeProjects(from, [])]); + if (db) { + // Leave no empty records behind for a namespace nobody is using. + await Promise.all([ + idbDelete(db, dbKey('graphs', from)), + idbDelete(db, dbKey('projects', from)), + ]); + } return moved; } From 15bc1e28e8db9cbb3d8a7d18410f40cd0a13b572 Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Tue, 28 Jul 2026 12:52:37 +0300 Subject: [PATCH 06/29] Close hard-delete gaps in the schema; harden version cap and metering Review found that RLS permits an owner to hard-delete a graph or project, and nothing cleaned up after that: - a hard-deleted graph left its public share slug resolving forever, so the diagram stayed readable to anyone holding the link - a hard-deleted project did the same - a hard-deleted graph left its whole version history behind, unreachable but retained until the account was deleted graph_versions now has a real foreign key to graphs with ON DELETE CASCADE (pre-existing orphans are dropped first so the constraint can validate). Shares deliberately keep no foreign key: the payload is a self-contained snapshot, so a diagram can be shared before sync has pushed its row, and a key would reject that insert. The purge trigger handles DELETE explicitly instead, reading OLD, and now fires on delete as well as on the soft-delete flip. Also: - enforce_graph_version_cap takes a transaction-scoped advisory lock keyed on the graph. Two devices inserting at once could each treat the other's row as retained and keep both, so the "cap" could be exceeded. - increment_ai_usage returns -1 for a non-positive limit. The limit was only checked on the conflict path, so the first generation of each month succeeded even with the quota set to zero. Verified against Postgres 16: schema applies cleanly and is idempotent; hard delete now leaves 0 shares and 0 versions (was 1 each); the cap still holds at 100 across 130 inserts; metering returns -1 at limit 0 and counts normally otherwise. Not changed: the review rated "purge_shares_for_deleted_content reads old.deleted on INSERT" as Critical, claiming it breaks every insert once shares exist. It does not. In a PL/pgSQL row-level INSERT trigger OLD is NULL rather than unassigned, so coalesce(old.deleted, false) is fine. Verified directly with shares present across plain insert, insert with deleted = true, project insert, and the tombstone upsert path: all succeed. --- supabase/schema.sql | 71 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/supabase/schema.sql b/supabase/schema.sql index 2354c32..68de282 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -205,6 +205,27 @@ create table if not exists public.graph_versions ( create index if not exists graph_versions_graph_idx on public.graph_versions (graph_id, created_at desc); +-- Soft deletes are handled by a trigger further down, but RLS also permits a +-- hard DELETE of a graph row, which would leave its history behind forever +-- (nothing else references graph_id). Cascade covers that path declaratively. +-- Orphans from before this constraint are dropped first, otherwise the +-- constraint cannot validate; their graph is already gone, so they are +-- unreachable rows. +delete from public.graph_versions v +where not exists (select 1 from public.graphs g where g.id = v.graph_id); + +do $$ +begin + if not exists ( + select 1 from pg_constraint where conname = 'graph_versions_graph_id_fkey' + ) then + alter table public.graph_versions + add constraint graph_versions_graph_id_fkey + foreign key (graph_id) references public.graphs (id) on delete cascade; + end if; +end; +$$; + alter table public.graph_versions enable row level security; drop policy if exists "graph_versions: select own" on public.graph_versions; @@ -265,6 +286,12 @@ security definer set search_path = public as $$ begin + -- Serialise per graph. Two devices inserting at once would otherwise each + -- see the other's row as still-retained and both keep it, leaving more than + -- the cap. The lock is transaction-scoped and keyed on the graph, so it only + -- ever blocks a concurrent insert for that same graph. + perform pg_advisory_xact_lock(hashtextextended(new.graph_id::text, 0)); + delete from public.graph_versions where graph_id = new.graph_id and user_id = new.user_id @@ -414,21 +441,38 @@ create policy "shares: delete own" -- Note this covers directly shared rows only. A graph deleted out of a SHARED -- PROJECT still needs the client to re-render that project's payload, since the -- payload is a snapshot the database can't rebuild. +-- Covers a hard DELETE as well as the soft delete: RLS lets an owner delete the +-- row outright, and shares deliberately carry no foreign key to graphs (the +-- payload is a self-contained snapshot, so a diagram can be shared before sync +-- has pushed its row). Without the DELETE branch, hard-deleting shared content +-- leaves its public slug resolving forever. create or replace function public.purge_shares_for_deleted_content() returns trigger language plpgsql security definer set search_path = public as $$ +declare + target_id uuid; + owner_id uuid; begin - if new.deleted and not coalesce(old.deleted, false) then - if tg_table_name = 'graphs' then - delete from public.shares - where user_id = new.user_id and kind = 'graph' and graph_id = new.id; - else - delete from public.shares - where user_id = new.user_id and kind = 'project' and project_id = new.id; - end if; + if tg_op = 'DELETE' then + target_id := old.id; + owner_id := old.user_id; + elsif new.deleted and not coalesce(old.deleted, false) then + -- On INSERT, old is NULL here (not unassigned), so coalesce is safe. + target_id := new.id; + owner_id := new.user_id; + else + return null; + end if; + + if tg_table_name = 'graphs' then + delete from public.shares + where user_id = owner_id and kind = 'graph' and graph_id = target_id; + else + delete from public.shares + where user_id = owner_id and kind = 'project' and project_id = target_id; end if; return null; end; @@ -438,12 +482,12 @@ revoke execute on function public.purge_shares_for_deleted_content() from public drop trigger if exists graphs_purge_shares_on_delete on public.graphs; create trigger graphs_purge_shares_on_delete - after insert or update of deleted on public.graphs + after insert or update of deleted or delete on public.graphs for each row execute function public.purge_shares_for_deleted_content(); drop trigger if exists projects_purge_shares_on_delete on public.projects; create trigger projects_purge_shares_on_delete - after insert or update of deleted on public.projects + after insert or update of deleted or delete on public.projects for each row execute function public.purge_shares_for_deleted_content(); -- ---------------------------------------------------------------------------- @@ -515,6 +559,13 @@ as $$ declare new_count integer; begin + -- A non-positive limit means "no generations allowed". Without this the very + -- first call each month would still succeed, because the limit is only + -- checked on the conflict path below. + if p_limit <= 0 then + return -1; + end if; + insert into public.ai_usage (user_id, month, count) values (p_user, p_month, 1) on conflict (user_id, month) do update From e4931b8fd0c505f747b68ea2ceb7bc9b0d3963e2 Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Tue, 28 Jul 2026 12:57:42 +0300 Subject: [PATCH 07/29] Fix data loss and stale-state bugs in the per-account store Storage writes never reported failure, so a move could destroy the only copy: adoptScope wrote the guest namespace into the account, then cleared the source unconditionally. If the destination write failed (a full quota is the realistic case) the work was gone. lsSet and the IndexedDB helpers now return whether the write actually landed, adoptScope returns null and keeps the source when it did not, and both migrations only drop a source once its copy is on disk. The IndexedDB helper also resolved on request success rather than transaction completion, which reports success for a transaction that later aborts. A failed first cloud pull was treated as proof the account was empty, so signed-out work could be adopted into an account whose cloud actually held diagrams: exactly the merge this design exists to prevent. decideGuestAdoption now takes firstPullFailed and waits instead. The editor no longer blocks on an adoption that can never resolve. Three more from review, all reachable: - scheduleAutosave never cleared its debounce handle, so after the first autosave applyRemote permanently believed edits were in flight and stopped refreshing the open diagram from other devices. - An account switch left historyIndexRef pointing into the old history. Ctrl+Z right after switching passed undo's guard and indexed past the new one-item array, feeding undefined to the canvas. - An account switch left pending history and autosave timers armed, so the outgoing account's diagram could be written into the incoming namespace. Also: a graph deleted on another device stayed open in the editor and kept being re-uploaded. applyRemote now closes it. Verified: 31 store/scenario tests, including a simulated quota failure proving the guest namespace survives a failed adoption, and the four adoption decisions. --- App.tsx | 55 +++++++++++++++++++++++--- services/localStore.ts | 89 ++++++++++++++++++++++++++++++------------ 2 files changed, 115 insertions(+), 29 deletions(-) diff --git a/App.tsx b/App.tsx index acb2f82..a5f56f0 100644 --- a/App.tsx +++ b/App.tsx @@ -208,7 +208,18 @@ export default function App() { const openId = activeGraphIdRef.current; if (openId && autosaveDebounceRef.current === null) { const incoming = remoteGraphs.find((g) => g.id === openId); - if (incoming && JSON.stringify(incoming.diagramData) !== JSON.stringify(currentDiagramRef.current)) { + if (!incoming) { + // Deleted on another device. The merge already dropped it, so leaving + // it open would keep editing (and re-uploading) a graph that no longer + // exists. Close it and let the auto-open effect pick the next one. + setActiveGraphId(null); + const blank = { ...EMPTY_DIAGRAM }; + setCurrentDiagram(blank); + setHistory([blank]); + historyRef.current = [blank]; + historyIndexRef.current = 0; + setHistoryIndex(0); + } else if (JSON.stringify(incoming.diagramData) !== JSON.stringify(currentDiagramRef.current)) { setCurrentDiagram(incoming.diagramData); setHistory([incoming.diagramData]); historyRef.current = [incoming.diagramData]; @@ -241,6 +252,14 @@ export default function App() { syncState.lastSyncedAt === null && (syncState.status === 'idle' || syncState.status === 'syncing' || syncState.status === 'disabled'); + // The first pull did not just fail to arrive, it failed outright. We cannot + // tell whether this account's cloud is empty, so any decision that depends on + // "the account has nothing" has to stay unresolved. + const firstPullFailed = + cloudConfigured && !!user && isPro && + syncState.lastSyncedAt === null && + (syncState.status === 'error' || syncState.status === 'offline'); + // --- Load shared editor preferences on mount --- // Diagrams and projects are NOT loaded here: they belong to whichever account // is signed in, which isn't known until the session has been restored. See @@ -301,6 +320,17 @@ export default function App() { setGraphs(stored.graphs); setProjects(stored.projects); setPendingGuestAdoption(guestPending); + // Drop timers armed by the outgoing account. A pending autosave would + // write its diagram into this namespace, and a pending history push would + // put it in the new account's undo stack. + if (historyDebounceRef.current !== null) { + window.clearTimeout(historyDebounceRef.current); + historyDebounceRef.current = null; + } + if (autosaveDebounceRef.current !== null) { + window.clearTimeout(autosaveDebounceRef.current); + autosaveDebounceRef.current = null; + } // Close whatever was open and blank the canvas: it belongs to the // namespace we're leaving. The auto-open effect below picks this // account's most recent diagram once its data is in place. @@ -309,6 +339,10 @@ export default function App() { setCurrentDiagram(blank); setHistory([blank]); historyRef.current = [blank]; + // undo/redo read the ref, not the state. Leaving it stale lets Ctrl+Z + // index past the end of the new one-item history and feed undefined into + // the canvas. + historyIndexRef.current = 0; setHistoryIndex(0); setLoadedScope(storeScope); setHasInitialized(true); @@ -329,6 +363,7 @@ export default function App() { pending: pendingGuestAdoption, scopeReady: loadedScope === storeScope, awaitingFirstPull, + firstPullFailed, accountHasContent: graphs.length > 0 || projects.length > 0, }); if (decision === 'wait') return; @@ -343,12 +378,15 @@ export default function App() { let cancelled = false; void (async () => { const adopted = await adoptScope(GUEST_SCOPE, storeScope); - if (cancelled) return; + // null means the copy failed and the work is still in the guest + // namespace. Leave this account empty rather than showing diagrams that + // were not actually saved to it. + if (cancelled || !adopted) return; setGraphs(adopted.graphs); setProjects(adopted.projects); })(); return () => { cancelled = true; }; - }, [pendingGuestAdoption, storeScope, loadedScope, awaitingFirstPull, graphs.length, projects.length]); + }, [pendingGuestAdoption, storeScope, loadedScope, awaitingFirstPull, firstPullFailed, graphs.length, projects.length]); // Keep live refs in sync for dep-free callbacks (see applyRemote). useEffect(() => { activeGraphIdRef.current = activeGraphId; }, [activeGraphId]); @@ -377,7 +415,9 @@ export default function App() { // Likewise, don't create one while work done signed out is about to be // handed to this account: that would leave a stray blank diagram beside it // (and select it, since the graph below is chosen unconditionally). - if (pendingGuestAdoption) return; + // A failed pull leaves that decision unresolved indefinitely, so don't + // hold the editor hostage to it. + if (pendingGuestAdoption && !firstPullFailed) return; // Create new graph if none exist const newGraph: Graph = { id: generateId(), @@ -400,7 +440,7 @@ export default function App() { historyRef.current = [newGraph.diagramData]; setHistoryIndex(0); } - }, [view, hasInitialized, activeGraphId, graphs.length, awaitingFirstPull, pendingGuestAdoption]); // Use graphs.length instead of graphs to avoid re-trigger on content changes + }, [view, hasInitialized, activeGraphId, graphs.length, awaitingFirstPull, pendingGuestAdoption, firstPullFailed]); // Use graphs.length instead of graphs to avoid re-trigger on content changes // --- Save to localStorage when data changes (only after initial load) --- // Only write once the namespace in memory is the one we last loaded. During an @@ -533,6 +573,11 @@ export default function App() { if (!activeGraphId) return; if (autosaveDebounceRef.current) window.clearTimeout(autosaveDebounceRef.current); autosaveDebounceRef.current = window.setTimeout(() => { + // Clear the handle first: applyRemote reads this ref to mean "unsaved + // edits are in flight, don't overwrite the canvas". Left set, it stays + // true forever after the first autosave and cross-device pulls silently + // stop refreshing the open diagram. + autosaveDebounceRef.current = null; setGraphs(prev => prev.map(g => g.id === activeGraphId ? { ...g, diagramData: data, title: data.title, lastModified: Date.now() } diff --git a/services/localStore.ts b/services/localStore.ts index d14df1a..73ecdfe 100644 --- a/services/localStore.ts +++ b/services/localStore.ts @@ -75,13 +75,17 @@ function lsGet(collection: Collection, scope: StoreScope): string | null { } } -function lsSet(collection: Collection, scope: StoreScope, raw: string | null): void { +/** Returns whether the write actually landed. Callers that then clear a source + * namespace MUST check this, or a failed write silently destroys the data. */ +function lsSet(collection: Collection, scope: StoreScope, raw: string | null): boolean { try { const key = localKey(collection, scope); if (raw === null) localStorage.removeItem(key); else localStorage.setItem(key, raw); + return true; } catch (e) { console.error(`Failed to write ${collection} for scope ${scope}:`, e); + return false; } } @@ -125,17 +129,30 @@ function openDb(): Promise { }); } -function idbRequest(db: IDBDatabase, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise { +/** + * `ok` distinguishes a genuine failure from a successful read that happens to + * return nothing. Writes are only safe to act on when `ok` is true: treating a + * failed write as success is how a move loses data. + */ +interface IdbResult { ok: boolean; value: T | null } + +function idbRequest(db: IDBDatabase, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise> { return new Promise((resolve) => { try { const tx = db.transaction(DB_STORE, mode); const req = run(tx.objectStore(DB_STORE)); - req.onsuccess = () => resolve(req.result as T); - req.onerror = () => resolve(null); - tx.onabort = () => resolve(null); + // Resolve on transaction completion for writes: a request can + // succeed and the transaction still abort (quota, for one). + req.onsuccess = () => { + if (mode === 'readonly') resolve({ ok: true, value: req.result as T }); + }; + tx.oncomplete = () => resolve({ ok: true, value: (req.result ?? null) as T | null }); + req.onerror = () => resolve({ ok: false, value: null }); + tx.onabort = () => resolve({ ok: false, value: null }); + tx.onerror = () => resolve({ ok: false, value: null }); } catch (e) { console.error('IndexedDB operation failed:', e); - resolve(null); + resolve({ ok: false, value: null }); } }); } @@ -184,8 +201,8 @@ function migrateToNamespaces(): void { const raw = lsGet(collection, GUEST_SCOPE); // Don't clobber an existing namespace if this somehow runs twice. if (raw !== null && lsGet(collection, owner) === null) { - lsSet(collection, owner, raw); - lsSet(collection, GUEST_SCOPE, null); + // Only drop the source once the copy is definitely on disk. + if (lsSet(collection, owner, raw)) lsSet(collection, GUEST_SCOPE, null); } } try { localStorage.removeItem(LEGACY_OWNER_KEY); } catch { /* ignore */ } @@ -216,10 +233,13 @@ async function migrateToIndexedDb(database: IDBDatabase): Promise { if (raw === null) continue; const existing = await idbGet(database, dbKey(collection, scope)); + if (!existing.ok) continue; // can't tell what's there; leave the source alone // Only seed a namespace IndexedDB doesn't already know about, so a // partially completed run can be repeated safely. - if (!Array.isArray(existing)) { - await idbPut(database, dbKey(collection, scope), parseArray(raw)); + if (!Array.isArray(existing.value)) { + const written = await idbPut(database, dbKey(collection, scope), parseArray(raw)); + // Keep localStorage as the copy of record until the move lands. + if (!written.ok) continue; } try { localStorage.removeItem(key); } catch { /* ignore */ } } @@ -279,32 +299,33 @@ export async function requestPersistentStorage(): Promise { async function readCollection(collection: Collection, scope: StoreScope): Promise { await ready(); if (db) { - const value = await idbGet(db, dbKey(collection, scope)); - return Array.isArray(value) ? value : []; + const result = await idbGet(db, dbKey(collection, scope)); + return Array.isArray(result.value) ? result.value : []; } return parseArray(lsGet(collection, scope)); } // Serialise writes per key: two rapid saves resolving out of order would // otherwise leave the older array on disk. -const writeQueues = new Map>(); +const writeQueues = new Map>(); -function enqueueWrite(key: string, op: () => Promise): Promise { - const previous = writeQueues.get(key) ?? Promise.resolve(); +function enqueueWrite(key: string, op: () => Promise): Promise { + const previous = writeQueues.get(key) ?? Promise.resolve(true); const next = previous.then(op, op).catch((e) => { console.error(`Failed to save ${key}:`, e); + return false; }); writeQueues.set(key, next); return next; } -async function writeCollection(collection: Collection, scope: StoreScope, items: T[]): Promise { +/** Resolves to whether the data is actually stored. */ +async function writeCollection(collection: Collection, scope: StoreScope, items: T[]): Promise { await ready(); const key = dbKey(collection, scope); - await enqueueWrite(key, async () => { - if (db) return idbPut(db, key, items); - lsSet(collection, scope, JSON.stringify(items)); - return undefined; + return enqueueWrite(key, async () => { + if (db) return (await idbPut(db, key, items)).ok; + return lsSet(collection, scope, JSON.stringify(items)); }); } @@ -317,11 +338,11 @@ export async function readScope(scope: StoreScope): Promise<{ graphs: Graph[]; p return { graphs, projects }; } -export function writeGraphs(scope: StoreScope, graphs: Graph[]): Promise { +export function writeGraphs(scope: StoreScope, graphs: Graph[]): Promise { return writeCollection('graphs', scope, graphs); } -export function writeProjects(scope: StoreScope, projects: Project[]): Promise { +export function writeProjects(scope: StoreScope, projects: Project[]): Promise { return writeCollection('projects', scope, projects); } @@ -351,11 +372,19 @@ export function decideGuestAdoption(input: { scopeReady: boolean; /** A Supporter whose first cloud pull hasn't landed yet. */ awaitingFirstPull: boolean; + /** + * A Supporter whose first pull failed (error/offline). An empty account is + * then unproven: the cloud may well hold diagrams we simply couldn't read. + */ + firstPullFailed: boolean; /** Whether the account has any diagrams or projects right now. */ accountHasContent: boolean; }): AdoptionDecision { if (!input.pending || !input.scopeReady) return 'wait'; if (input.awaitingFirstPull) return 'wait'; + // Never treat "we couldn't reach the cloud" as "the account is empty": + // adopting on that basis mixes signed-out work into someone's real library. + if (input.firstPullFailed) return 'wait'; return input.accountHasContent ? 'keep-separate' : 'adopt'; } @@ -366,10 +395,22 @@ export function decideGuestAdoption(input: { * The caller must have established that the destination is empty: this * overwrites rather than merges, precisely so two people's diagrams are never * silently mixed together. + * + * Returns null if the copy did not land, leaving the source untouched. Clearing + * the source on a failed write would destroy the only copy, which is the whole + * thing this namespacing exists to prevent. */ -export async function adoptScope(from: StoreScope, to: StoreScope): Promise<{ graphs: Graph[]; projects: Project[] }> { +export async function adoptScope(from: StoreScope, to: StoreScope): Promise<{ graphs: Graph[]; projects: Project[] } | null> { const moved = await readScope(from); - await Promise.all([writeGraphs(to, moved.graphs), writeProjects(to, moved.projects)]); + const [graphsSaved, projectsSaved] = await Promise.all([ + writeGraphs(to, moved.graphs), + writeProjects(to, moved.projects), + ]); + if (!graphsSaved || !projectsSaved) { + console.error(`Could not move ${from} into ${to}; leaving the source in place.`); + return null; + } + await Promise.all([writeGraphs(from, []), writeProjects(from, [])]); if (db) { // Leave no empty records behind for a namespace nobody is using. From 99c76d644a3f8239fcecf8ddb2695b9e5488aebd Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Tue, 28 Jul 2026 13:03:11 +0300 Subject: [PATCH 08/29] Fix billing verification, share revocation, and cross-account leaks api/delete-account: stop trusting our own profile row. A missing profiles row, or a subscription id never written because a webhook was lost, meant deletion proceeded with no billing check and could leave a live subscription charging a deleted account. Ask Polar directly by external customer id, and cancel everything it reports plus anything our row knows about. A failed lookup now returns 503 instead of telling the user to go cancel manually, which also fixes missing Polar configuration being reported as cancel_failed. api/portal: resolve the Polar client outside the try, so an unconfigured deployment answers 503 rather than "No billing account found, wait a few seconds and try again", which sent the user in circles. api/usage: a failed profile lookup was reported as isPro:false with HTTP 200, indistinguishable from a lapsed plan. Now 503, matching /api/generate. services/shares: revokeShare reported success when the delete removed nothing. The delete policy is owner-scoped, so a mismatched id or an RLS denial silently affected zero rows while the UI cleared the link and the URL kept resolving. It now selects the deleted row and errors on an empty result. Cross-account leaks, all the same shape (a response landing after the account changed): the hosted usage meter, the custom template library, and the auth profile, which kept showing the previous account's Supporter status until the replacement query returned. services/keyObfuscation: btoa throws on any character above U+00FF, so a key pasted with a smart quote or non-Latin text crashed the settings save. Now round-trips through UTF-8 bytes. Existing stored keys are ASCII and decode unchanged. vite.config (dev server only): reject path traversal out of api/, and cap the request body at 2MB so one oversized request can't exhaust the dev process. ComponentLibrary: Enter in the template name field called the save handler directly, bypassing the button's disabled state, so repeated presses could create duplicate templates or save a blank name. Verified: key obfuscation round-trips smart quotes, CJK and emoji (all previously threw) and still passes legacy plain values through; every raw traversal path returns 404 with no file contents while /api/usage and the SPA still serve. --- api/delete-account.ts | 32 +++++++++++++++++++++++++++----- api/portal.ts | 12 +++++++++++- api/usage.ts | 13 ++++++++++++- components/AccountSection.tsx | 10 +++++++--- components/ComponentLibrary.tsx | 9 ++++++++- services/auth.tsx | 4 ++++ services/keyObfuscation.ts | 20 ++++++++++++++++++-- services/shares.ts | 12 ++++++++++-- vite.config.ts | 22 +++++++++++++++++++++- 9 files changed, 118 insertions(+), 16 deletions(-) diff --git a/api/delete-account.ts b/api/delete-account.ts index 4d459df..dc4c62e 100644 --- a/api/delete-account.ts +++ b/api/delete-account.ts @@ -45,12 +45,34 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { }); } - // Try to cancel whenever a subscription id is on file, without trusting our - // own pro_status: if that column is stale (a missed webhook), gating on it + // Ask Polar what this user actually has, rather than trusting our own row. + // A profile can be missing entirely, or its subscription id can be stale + // because a webhook was never delivered; in either case gating on our copy // would skip cancellation and leave a live subscription billing a deleted - // account. Revoking something already inactive is handled below. - if (profile?.polar_subscription_id) { - const subId = profile.polar_subscription_id; + // account. Polar is the authority, so query it by external customer id. + let liveSubscriptionIds: string[]; + try { + const page = await getPolar().subscriptions.list({ externalCustomerId: user.id, active: true }); + const ids = new Set(); + for await (const chunk of page) { + for (const sub of chunk.result.items) { + if (ACTIVE_STATUSES.has(sub.status ?? '')) ids.add(sub.id); + } + } + // Belt and braces: cancel anything our own row knows about too, in case + // Polar's active filter and our status set ever disagree. + if (profile?.polar_subscription_id) ids.add(profile.polar_subscription_id); + liveSubscriptionIds = [...ids]; + } catch (err) { + // Includes "Polar isn't configured on this deployment", which is a + // server problem: don't tell the user to go cancel something manually. + console.error('delete-account: could not list subscriptions', err); + return res.status(503).json({ + error: 'Could not verify your billing status right now. Please try again in a moment.', + }); + } + + for (const subId of liveSubscriptionIds) { try { await getPolar().subscriptions.revoke({ id: subId }); } catch (err) { diff --git a/api/portal.ts b/api/portal.ts index c075310..0bfcf35 100644 --- a/api/portal.ts +++ b/api/portal.ts @@ -23,8 +23,18 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(401).json({ error: 'Please sign in first.' }); } + // Resolve the client outside the try: a missing POLAR_ACCESS_TOKEN is a + // deployment problem, not "you have no billing account", and telling the + // user to wait and retry would send them in circles. + let polar; + try { + polar = getPolar(); + } catch (err) { + console.error('portal: Polar is not configured', err); + return res.status(503).json({ error: 'Billing is not configured on this deployment.' }); + } + try { - const polar = getPolar(); const session = await polar.customerSessions.create({ externalCustomerId: user.id, }); diff --git a/api/usage.ts b/api/usage.ts index e553246..136c39d 100644 --- a/api/usage.ts +++ b/api/usage.ts @@ -26,8 +26,16 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(401).json({ error: 'Not signed in.' }); } + // A lookup failure must not read as "not a Supporter": the caller would show + // a lapsed plan to someone whose plan is fine. Distinguish it from a genuine + // null profile by capturing the error. + let profileFailed = false; const [profile, usageResult] = await Promise.all([ - getProfile(user.id).catch(() => null), + getProfile(user.id).catch((err) => { + console.error('usage: profile lookup failed', err); + profileFailed = true; + return null; + }), getSupabaseAdmin() .from('ai_usage') .select('count') @@ -42,6 +50,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { console.error('usage: failed to read ai_usage', usageResult.error); return res.status(503).json({ error: 'Usage service is temporarily unavailable.' }); } + if (profileFailed) { + return res.status(503).json({ error: 'Could not confirm your plan right now. Please try again in a moment.' }); + } const used = usageResult.data?.count ?? 0; return res.status(200).json({ diff --git a/components/AccountSection.tsx b/components/AccountSection.tsx index 7467942..a232716 100644 --- a/components/AccountSection.tsx +++ b/components/AccountSection.tsx @@ -63,11 +63,15 @@ const AccountSection: React.FC = ({ syncState, onSyncNow, o // Hosted usage meter useEffect(() => { - if (user && isPro) { - fetchHostedUsage().then(setUsage); - } else { + if (!user || !isPro) { setUsage(null); + return; } + // Ignore a response that arrives after the account changed, otherwise + // the meter can show the previous account's generation count. + let cancelled = false; + fetchHostedUsage().then((u) => { if (!cancelled) setUsage(u); }); + return () => { cancelled = true; }; }, [user, isPro]); // Checkout return flow: ?checkout=success → poll until webhook lands diff --git a/components/ComponentLibrary.tsx b/components/ComponentLibrary.tsx index 389c04a..0583c7f 100644 --- a/components/ComponentLibrary.tsx +++ b/components/ComponentLibrary.tsx @@ -74,13 +74,20 @@ const ComponentLibrary: React.FC = ({ } setCustomTemplates(listCachedTemplates(user.id)); if (isOpen && isPro) { - fetchCustomTemplates(user.id).then(setCustomTemplates); + // Drop a response that lands after sign-out or an account switch, + // which would otherwise repopulate the library from the old account. + let cancelled = false; + fetchCustomTemplates(user.id).then((t) => { if (!cancelled) setCustomTemplates(t); }); + return () => { cancelled = true; }; } }, [isOpen, user, isPro]); if (!isOpen) return null; const handleSaveTemplate = async () => { + // The button is disabled for these, but Enter in the name field calls + // this directly, so repeated presses could create duplicate templates. + if (saving || !saveName.trim()) return; if (!user) { setSaveError('Sign in (Settings) to save templates.'); return; diff --git a/services/auth.tsx b/services/auth.tsx index 1a8e646..1bfd7bb 100644 --- a/services/auth.tsx +++ b/services/auth.tsx @@ -121,6 +121,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children if (newUserId !== userIdRef.current) { if (!newUserId) clearTemplateCache(); // signed out / expired elsewhere userIdRef.current = newUserId; + // Drop the old profile immediately. Leaving it in place until the + // replacement query resolves shows the previous account's name + // and Supporter status under the new session. + setProfile(null); fetchProfile(newUserId); } }); diff --git a/services/keyObfuscation.ts b/services/keyObfuscation.ts index 471273d..f770e09 100644 --- a/services/keyObfuscation.ts +++ b/services/keyObfuscation.ts @@ -4,11 +4,27 @@ // exposure (e.g. shoulder-surfing DevTools). Shared by every BYO-key provider. const OBFUSCATION_PREFIX = 'egk_'; +// btoa/atob only handle Latin-1. A key pasted with any character above U+00FF +// (or a stray smart quote) would throw InvalidCharacterError out of the save +// path, so round-trip through UTF-8 bytes instead. +function toBase64(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function fromBase64(encoded: string): string { + const binary = atob(encoded); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + export function obfuscateKey(key: string): string { - return OBFUSCATION_PREFIX + btoa(key); + return OBFUSCATION_PREFIX + toBase64(key); } export function deobfuscateKey(stored: string): string { if (!stored.startsWith(OBFUSCATION_PREFIX)) return stored; - return atob(stored.slice(OBFUSCATION_PREFIX.length)); + return fromBase64(stored.slice(OBFUSCATION_PREFIX.length)); } diff --git a/services/shares.ts b/services/shares.ts index effb4a4..488d99d 100644 --- a/services/shares.ts +++ b/services/shares.ts @@ -173,8 +173,16 @@ export async function createOrUpdateProjectShare( export async function revokeShare(shareId: string): Promise<{ error?: string }> { if (!supabase) return { error: 'Sharing is not available on this deployment.' }; - const { error } = await supabase.from('shares').delete().eq('id', shareId); - return error ? { error: error.message } : {}; + // Ask for the deleted row back. The delete policy is scoped to the owner, so + // an id that doesn't match (or an RLS denial) removes nothing and still + // reports success. Telling someone their link is revoked while it keeps + // resolving is the worst possible outcome here. + const { data, error } = await supabase.from('shares').delete().eq('id', shareId).select('id'); + if (error) return { error: error.message }; + if (!data || data.length === 0) { + return { error: 'Could not revoke that link. Please reload and try again.' }; + } + return {}; } /** diff --git a/vite.config.ts b/vite.config.ts index 58adcd7..ae88061 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,6 +24,14 @@ function devApiPlugin(root: string): Plugin { const parsed = new URL(req.url, 'http://localhost'); const rel = parsed.pathname.replace(/^\/api\//, '').replace(/\/+$/, ''); + // `/api/../../secret` would otherwise escape the api directory + // through path.join. Only plain nested route segments are valid. + if (!/^[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*$/.test(rel)) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: `No API route for ${parsed.pathname}` })); + return; + } const variants = [ { abs: path.join(root, 'api', `${rel}.ts`), id: `/api/${rel}.ts` }, { abs: path.join(root, 'api', rel, 'index.ts'), id: `/api/${rel}/index.ts` }, @@ -83,10 +91,22 @@ function devApiPlugin(root: string): Plugin { }; } +/** Cap the dev-server body so one oversized request can't exhaust the process. */ +const MAX_DEV_BODY_BYTES = 2 * 1024 * 1024; + function readJsonBody(req: any): Promise { return new Promise((resolve) => { const chunks: Buffer[] = []; - req.on('data', (c: Buffer) => chunks.push(c)); + let size = 0; + req.on('data', (c: Buffer) => { + size += c.length; + if (size > MAX_DEV_BODY_BYTES) { + req.destroy(); + resolve(undefined); + return; + } + chunks.push(c); + }); req.on('end', () => { if (chunks.length === 0) return resolve(undefined); const raw = Buffer.concat(chunks).toString('utf8'); From 26c22948a08c3abc8cc77b2eb2a806d2b9a78dee Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Tue, 28 Jul 2026 13:11:09 +0300 Subject: [PATCH 09/29] Fix intersection geometry in SEO diagram illustrations Every labelled point on the generated diagram SVGs is supposed to sit on the crossing it names, and a dashed dropline is rendered from it to the axis, so a misplaced point is visible. - monopoly: MR was drawn with demand's slope. For D = AR = 100 - Q the marginal revenue curve is 100 - 2Q (same intercept, twice the slope). MC = MR then lands at Q = 31.9, and P_m reads off demand at 68.1. - negative externalities: MSC was not parallel to MPC, contradicting the page's own "keep MSC parallel to MPC" instruction. Made it a constant external cost of 20; Q* moves to (40, 60). - positive externalities: same problem between MSB and MPB. Both are now slope -1 with an external benefit of 20, putting Q_1 at (45, 45) and Q* at (55, 55). - AD-AS: short-run equilibrium was 4 units off the AD/SRAS crossing. - subsidy: S-sub was not parallel to S, so the vertical gap was not a constant per-unit subsidy. Both equilibria were also off. - perfect competition: Q* sat 1.1 units past where the rising branch of MC cuts the price line. --- scripts/seo-content.mjs | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/scripts/seo-content.mjs b/scripts/seo-content.mjs index abab4c9..6b740c1 100644 --- a/scripts/seo-content.mjs +++ b/scripts/seo-content.mjs @@ -107,11 +107,14 @@ export const DIAGRAM_PAGES = [ axes: ['Quantity (Q)', 'Price, Costs (P)'], diagram: { lines: [ + // D = AR is P = 100 - Q, so MR = 100 - 2Q: same price intercept, + // twice the slope. Drawn from Q=10 (MR=80) to where MR hits 10. [10, 90, 90, 10, '#ef4444', 'D=AR'], - [10, 90, 55, 10, '#ec4899', 'MR', true], + [10, 80, 45, 10, '#ec4899', 'MR', true], [10, 15, 85, 88, '#3b82f6', 'MC'], ], - points: [[35, 44, 'MC=MR'], [35, 68, 'P_m']], + // MC = MR at Q = 31.9; P_m is read off demand at that quantity. + points: [[31.9, 36.3, 'MC=MR'], [31.9, 68.1, 'P_m']], }, related: ['perfect-competition', 'supply-and-demand', 'negative-externalities'], }, @@ -159,9 +162,12 @@ export const DIAGRAM_PAGES = [ lines: [ [10, 90, 90, 10, '#ef4444', 'MPB'], [10, 10, 90, 90, '#3b82f6', 'MPC'], - [10, 30, 78, 95, '#648d49', 'MSC'], + // "Keep MSC parallel to MPC" per the howToDraw steps above: same + // slope, shifted up by a constant marginal external cost of 20. + [10, 30, 75, 95, '#648d49', 'MSC'], ], - points: [[50, 50, 'Q_1'], [40, 55, 'Q^*']], + // Q_1 is MPC = MPB; Q* is MSC = MPB (the social optimum). + points: [[50, 50, 'Q_1'], [40, 60, 'Q^*']], }, related: ['positive-externalities', 'tax-incidence', 'subsidy-diagram'], }, @@ -207,11 +213,14 @@ export const DIAGRAM_PAGES = [ axes: ['Quantity (Q)', 'Costs / Benefits (P)'], diagram: { lines: [ - [10, 80, 85, 10, '#ef4444', 'MPB'], - [18, 95, 90, 22, '#648d49', 'MSB'], + [10, 80, 80, 10, '#ef4444', 'MPB'], + // MSB parallel to MPB ("MSB > MPB at every quantity"), shifted + // up by a constant marginal external benefit of 20. + [20, 90, 90, 20, '#648d49', 'MSB'], [10, 10, 90, 90, '#3b82f6', 'MSC'], ], - points: [[45, 45, 'Q_1'], [56, 56, 'Q^*']], + // Q_1 is MPB = MSC (the market underconsumes); Q* is MSB = MSC. + points: [[45, 45, 'Q_1'], [55, 55, 'Q^*']], }, related: ['negative-externalities', 'subsidy-diagram', 'supply-and-demand'], }, @@ -361,7 +370,8 @@ export const DIAGRAM_PAGES = [ [15, 12, 88, 85, '#3b82f6', 'SRAS'], [65, 5, 65, 95, '#64748b', 'LRAS'], ], - points: [[52, 43, 'Y_1']], + // Short-run equilibrium: AD meets SRAS. + points: [[47.9, 44.9, 'Y_1']], }, related: ['exchange-rate-diagram', 'ppc-diagram', 'supply-and-demand'], }, @@ -413,7 +423,9 @@ export const DIAGRAM_PAGES = [ [10, 60, 40, 15, 90, 90, '#22c55e', 'MC'], [10, 85, 50, 40, 90, 80, '#8b5cf6', 'ATC'], ], - points: [[62, 55, 'Q^*']], + // Profit-maximising output: where the rising branch of MC cuts the + // price line from below. + points: [[60.9, 55, 'Q^*']], }, related: ['monopoly-diagram', 'supply-and-demand', 'ppc-diagram'], }, @@ -559,9 +571,12 @@ export const DIAGRAM_PAGES = [ lines: [ [10, 90, 90, 10, '#ef4444', 'D'], [10, 25, 90, 95, '#3b82f6', 'S'], - [18, 10, 90, 72, '#22c55e', 'S-sub', true], + // A per-unit subsidy shifts S down by a constant amount, so + // S-sub must be parallel to S (slope 0.875, gap of 15). + [10, 10, 90, 80, '#22c55e', 'S-sub', true], ], - points: [[47, 55, 'E'], [58, 46, 'E_1']], + // E is S = D; E_1 is S-sub = D. + points: [[44.7, 55.3, 'E'], [52.7, 47.3, 'E_1']], }, related: ['tax-incidence', 'positive-externalities', 'price-ceilings-and-floors'], }, From 597777bb263f15fc59d4ea4592832f03896511fa Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Tue, 28 Jul 2026 13:21:18 +0300 Subject: [PATCH 10/29] Validate checkout redirect origins; bound the model call; docs and copy fixes Server: - getAppUrl built the Polar checkout success/cancel URLs straight from the request's Origin (or Host) header. On any deployment that is not Vercel + APP_URL, a caller could point that post-payment redirect at a site of their choosing. Candidates now have to clear an allowlist: APP_URL, the new optional ALLOWED_ORIGINS, and - outside production only - localhost and the dev-tunnel providers already listed in vite.config.ts. - /api/generate had no bound on the upstream model call, so a hung request was only stopped by the platform function timeout, which kills the process before the refund path can run and costs the user a credit for a generation they never got. Added a 30s AbortSignal, a distinct "took too long" message, and an explicit maxDuration so the abort always fires first. - resolveAiClient() rebuilt the client (and re-parsed the service-account JSON) on every request; memoised, since it only reads env vars. - Dropped AiConfig.mode, which was set in all three branches and never read. Content: - Legal page promised "unlimited generations" on a bring-your-own key; now says BYOK is not metered by this app but is subject to the provider's limits and costs. - "Full IB Curriculum" card had dropped development economics. - Shared-view footer rendered "IB EconGraph AI , the free...". - Fixed a sentence with no main clause in the AI system prompt. Docs: - BACKEND_SETUP documented a 3-day billing grace period; the webhook grants 1 (ACTIVE_MARGIN_DAYS). - Labelled the three dotenv code fences (MD040). - dev:api now runs npx vercel dev, so it works without a global CLI install, and the docs say why the CLI is not a devDependency. - CHANGELOG version links pointed at release tags; the repo has no tags or releases at all, so both 1.1.0 and 1.0.0 would 404. Removed them with a note to restore once tagged. - Keepalive curl had no timeout, so a hung connection would hold the runner until GitHub's 6h limit. --- .github/workflows/db-keepalive.yml | 3 ++ CHANGELOG.md | 7 +++- api/_lib/polar.ts | 67 +++++++++++++++++++++++++----- api/generate.ts | 60 +++++++++++++++++++++----- components/LandingPage.tsx | 2 +- components/LegalPages.tsx | 3 +- components/SharedViewPage.tsx | 4 +- docs/BACKEND_SETUP.md | 28 +++++++++---- package.json | 2 +- services/diagramPrompt.ts | 2 +- vercel.json | 5 +++ 11 files changed, 147 insertions(+), 36 deletions(-) diff --git a/.github/workflows/db-keepalive.yml b/.github/workflows/db-keepalive.yml index 4bcafd9..168f6f1 100644 --- a/.github/workflows/db-keepalive.yml +++ b/.github/workflows/db-keepalive.yml @@ -34,7 +34,10 @@ jobs: fi # One-row read via PostgREST. The secret key bypasses RLS, so this is a # trivial query that still counts as real database activity. + # Bound the request: without a timeout a hung connection would stall + # the job until GitHub's 6h default runner limit. code=$(curl -s -o /dev/null -w '%{http_code}' \ + --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 5 \ "$SUPABASE_URL/rest/v1/profiles?select=id&limit=1" \ -H "apikey: $SUPABASE_SECRET_KEY" \ -H "Authorization: Bearer $SUPABASE_SECRET_KEY") diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a2ce1..3187838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,5 +90,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Box select and eraser tools - Pan and zoom controls -[1.1.0]: https://github.com/sukarth/IB-EconGraph-AI/releases/tag/v1.1.0 -[1.0.0]: https://github.com/sukarth/IB-EconGraph-AI/releases/tag/v1.0.0 + diff --git a/api/_lib/polar.ts b/api/_lib/polar.ts index 9f019ab..6320296 100644 --- a/api/_lib/polar.ts +++ b/api/_lib/polar.ts @@ -15,9 +15,54 @@ export function getPolar(): Polar { return cached; } -export function getAppUrl(req: { headers: Record }): string { - const clean = (u: string) => u.replace(/\/$/, ''); +const clean = (u: string) => u.replace(/\/$/, ''); + +const DEFAULT_APP_URL = 'https://ib-econgraph-ai.vercel.app'; + +/** + * Public dev-tunnel providers, mirroring `server.allowedHosts` in + * `vite.config.ts`. These are trusted only outside production (see + * `isAllowedOrigin`), where they exist so Polar redirects and webhooks can be + * tested against a real HTTPS origin. + */ +const DEV_TUNNEL_SUFFIXES = ['.devtunnels.ms', '.ngrok-free.app', '.ngrok.app', '.trycloudflare.com']; + +/** Origins this deployment is willing to redirect a checkout back to. */ +function configuredOrigins(): string[] { + const list: string[] = []; + if (process.env.APP_URL) list.push(clean(process.env.APP_URL)); + for (const extra of (process.env.ALLOWED_ORIGINS || '').split(',')) { + const trimmed = extra.trim(); + if (trimmed) list.push(clean(trimmed)); + } + return list; +} +/** + * The checkout success/cancel URLs are handed to Polar, which redirects the + * browser there after payment. Building them from a raw `Origin` (or `Host`) + * header would let a caller point that redirect at any site they like, so every + * candidate has to clear an allowlist first. + */ +function isAllowedOrigin(candidate: string): boolean { + let url: URL; + try { + url = new URL(candidate); + } catch { + return false; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + if (configuredOrigins().includes(clean(url.origin))) return true; + + // Development conveniences, deliberately unavailable in production: a + // self-hosted production deployment must name its origins via APP_URL / + // ALLOWED_ORIGINS. + if (process.env.NODE_ENV === 'production') return false; + if (/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(url.hostname)) return true; + return DEV_TUNNEL_SUFFIXES.some((suffix) => url.hostname.endsWith(suffix)); +} + +export function getAppUrl(req: { headers: Record }): string { // On Vercel (production or preview), prefer the configured canonical domain // so checkout redirects land on the primary URL rather than a *.vercel.app // alias. VERCEL is set automatically in every Vercel deployment. @@ -25,19 +70,21 @@ export function getAppUrl(req: { headers: Record abort.abort(), MODEL_TIMEOUT_MS); try { const { ai, model } = aiConfig; const response = await ai.models.generateContent({ @@ -177,22 +201,36 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { responseMimeType: 'application/json', responseSchema: GEMINI_DIAGRAM_SCHEMA, temperature: 0.2, + abortSignal: abort.signal, }, }); responseText = response.text || '{}'; } catch (err) { - // The upstream call itself failed, no generation was produced (and we - // weren't billed), so it's fair to refund the metered credit. This is - // the ONLY refund path: a response that comes back but fails to parse - // below still counts as a used generation, so it can't be farmed to - // burn the hosted key for free. - console.error('generate: Gemini call failed', err); + // The upstream call failed or timed out, so no diagram reached the user + // and the metered credit is refunded. This is the ONLY refund path: a + // response that comes back but fails to parse below still counts as a + // used generation, so it can't be farmed to burn the hosted key for + // free. (On a timeout the provider may still bill us upstream, since + // aborting is client-side only, but charging the user for nothing they + // received would be worse.) + console.error( + abort.signal.aborted + ? `generate: Gemini call exceeded ${MODEL_TIMEOUT_MS}ms and was aborted` + : 'generate: Gemini call failed', + err, + ); await admin .rpc('refund_ai_usage', { p_user: user.id, p_month: month }) .then(({ error }) => { if (error) console.error('generate: refund failed', error); }); - return res.status(502).json({ error: 'The AI generation failed. Please try again.' }); + return res.status(502).json({ + error: abort.signal.aborted + ? 'The AI took too long to respond. Please try again.' + : 'The AI generation failed. Please try again.', + }); + } finally { + clearTimeout(timer); } try { diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index e7b900e..931ca0d 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -673,7 +673,7 @@ const LandingPage: React.FC = ({ onGoHome, onOpenPricing, onOp { icon: , title: 'Full IB Curriculum', - desc: 'Covers all the IB Economics topics: micro, macro and international economics.', + desc: 'Covers all the IB Economics topics: micro, macro, international and development economics.', color: 'text-amber-600 bg-amber-100', }, ].map((item, i) => ( diff --git a/components/LegalPages.tsx b/components/LegalPages.tsx index 9cc89d7..d53d2e4 100644 --- a/components/LegalPages.tsx +++ b/components/LegalPages.tsx @@ -207,7 +207,8 @@ export const TermsPage: React.FC = () => ( Hosted AI generation is included with the Supporter plan up to a monthly limit (currently 150 generations). It's for normal, personal use in creating economics diagrams. Automated abuse, reselling, or attempts to extract or overuse the underlying AI service may be rate-limited or - suspended. You can always switch to your own free API key for unlimited generations. + suspended. You can always switch to your own API key instead. Bring-your-own-key generation is + not metered by this app, but it stays subject to your provider's own limits, usage rules and costs.

    diff --git a/components/SharedViewPage.tsx b/components/SharedViewPage.tsx index 3b39312..2b07525 100644 --- a/components/SharedViewPage.tsx +++ b/components/SharedViewPage.tsx @@ -169,8 +169,8 @@ const SharedViewPage: React.FC = ({ slug, onGoHome }) => { Made with{' '} {' '} - , the free, open-source economics diagram editor for IB students. + + , the free, open-source economics diagram editor for IB students. ); diff --git a/docs/BACKEND_SETUP.md b/docs/BACKEND_SETUP.md index b9b3656..2e2e62b 100644 --- a/docs/BACKEND_SETUP.md +++ b/docs/BACKEND_SETUP.md @@ -98,7 +98,7 @@ Agent Platform* in 2026, but the API is the same. Express mode gives you a singl API key with no service account, so it just works on serverless. Create the key in the Google Cloud console (express mode), then set: -``` +```dotenv VERTEX_API_KEY=... # Vertex express-mode API key HOSTED_AI_MONTHLY_LIMIT=150 HOSTED_AI_MODEL=gemini-2.5-flash @@ -118,7 +118,7 @@ server authenticates with your gcloud Application Default Credentials, so run must also create a service account with the *Vertex AI User* role and paste its key JSON, as a single line, into `GOOGLE_SERVICE_ACCOUNT_JSON`: -``` +```dotenv GOOGLE_CLOUD_PROJECT=your-project-id GOOGLE_CLOUD_LOCATION=global GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account", ...} # Vercel only @@ -129,7 +129,7 @@ HOSTED_AI_MODEL=gemini-2.5-flash **Option C — Gemini Developer API (Google AI Studio).** The simplest fully-free option. Get a key at : -``` +```dotenv GEMINI_API_KEY=... # Google AI Studio key HOSTED_AI_MONTHLY_LIMIT=150 HOSTED_AI_MODEL=gemini-2.5-flash @@ -161,8 +161,9 @@ through Google Cloud; AI Studio (C) has a free tier. 5. Polar acts as **merchant of record**, so EU VAT is handled for you. The webhook keeps `profiles.pro_status` / `pro_until` in sync. Entitlement = -`pro_until > now()`; the server grants a 3-day grace period past each billing -period end so renewals never cause flapping. +`pro_until > now()`; the server grants a 1-day grace period past each billing +period end so renewals never cause flapping. (`ACTIVE_MARGIN_DAYS` in +`api/webhooks/polar.ts`.) ## 4. Vercel environment variables — summary @@ -185,6 +186,16 @@ period end so renewals never cause flapping. | `POLAR_PRODUCT_ID_YEARLY` | server | yearly product | | `POLAR_SERVER` | server | `production` or `sandbox` | | `APP_URL` | server | canonical site URL for checkout redirects | +| `ALLOWED_ORIGINS` | server | *optional*, comma-separated extra origins allowed as checkout redirect targets | + +Checkout success/cancel URLs are handed to Polar, which redirects the browser +there after payment, so they are never taken straight from the request's +`Origin`/`Host` header. An origin is accepted only if it matches `APP_URL` or an +entry in `ALLOWED_ORIGINS`; outside production (`NODE_ENV !== 'production'`), +localhost and the dev-tunnel providers listed in `api/_lib/polar.ts` are also +accepted. Anything else falls back to `APP_URL`. A self-hosted production +deployment serving more than one domain must list the extras in +`ALLOWED_ORIGINS`. ## 5. Testing the full flow @@ -193,8 +204,11 @@ period end so renewals never cause flapping. > `/api/checkout`, `/api/usage`, etc. work on `http://localhost:4000` with no > Vercel CLI needed — it reads your local `.env` for the server-side vars. For > local checkout redirects, set `APP_URL=http://localhost:4000`. -> (`npm run dev:api` = `vercel dev` is an alternative that runs the real Vercel -> runtime, but it needs `vercel login`/`link` and is finicky on Windows + Node 24.) +> (`npm run dev:api` = `npx vercel dev` is an alternative that runs the real +> Vercel runtime. The CLI is deliberately *not* in `devDependencies` — it is a +> large install that most contributors never need — so `npx` fetches it on +> first use. It also needs `vercel login`/`link` and is finicky on +> Windows + Node 24.) > > **Webhook reachability:** the entitlement flip to Supporter is driven by the > Polar `subscription.*` webhook, and Polar (even in sandbox) can only reach a diff --git a/package.json b/package.json index 4e40bdd..269b363 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ ], "scripts": { "dev": "vite", - "dev:api": "vercel dev --listen 4000", + "dev:api": "npx vercel dev --listen 4000", "build": "vite build && node scripts/generate-seo-pages.mjs", "preview": "vite preview" }, diff --git a/services/diagramPrompt.ts b/services/diagramPrompt.ts index 624745b..5723275 100644 --- a/services/diagramPrompt.ts +++ b/services/diagramPrompt.ts @@ -11,7 +11,7 @@ export const DIAGRAM_SYSTEM_INSTRUCTION = ` 1. Coordinate System: Use a logical scale (e.g., 0-10 or 0-100). Keep it consistent. 2. Accuracy: Calculate intersection points mathematically. If Supply is P = 10 + Q and Demand is P = 100 - Q, Equilibrium is Q=45, P=55. 3. Shared Coordinates (CRITICAL): - - If an equilibrium point E is at (50, 50), ensuring the Supply Curve, Demand Curve, and any Shaded Regions ALL use the exact coordinate (50, 50). + - If an equilibrium point E is at (50, 50), ensure the Supply Curve, Demand Curve, and any Shaded Regions ALL use the exact coordinate (50, 50). - Do not approximate. If a shaded region (e.g., Consumer Surplus) is bounded by the Price axis, Demand curve, and Equilibrium price, the vertices must strictly match the curve points. 4. Shading: - Provide a closed polygon for shaded areas. diff --git a/vercel.json b/vercel.json index d19a8f6..2892ca3 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,11 @@ { "cleanUrls": true, "trailingSlash": false, + "functions": { + "api/generate.ts": { + "maxDuration": 60 + } + }, "rewrites": [ { "source": "/((?!api/).*)", From e6cbd95075453bf950f8bc0a70e1d568ae21f963 Mon Sep 17 00:00:00 2001 From: Sukarth Acharya Date: Tue, 28 Jul 2026 13:23:39 +0300 Subject: [PATCH 11/29] Accessibility: field names, table semantics, keyboard-reachable template rows - AuthModal's email and password inputs had only placeholders, so a screen reader announced no field name. Added aria-labels (three inputs, including the reset form's). - ComparePage: row labels were plain , so a cell could not be associated with its row; the horizontally scrolling wrapper around a min-w-[760px] table had no way to be scrolled by keyboard. Added scope="row"/"col", a named focusable region, and a screen-reader-only name for the empty corner header. - Custom template rows were a div with onClick only, so keyboard users could not add a saved template. They cannot become