- 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{' '}
+
+ Supporter plan
+ {' '}
+ keep this tool free for every student. Thank you.
+
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.
+ 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.
+
+ 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.
+ 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.
+
+ Create a view-only link for
+ {' '}{graph?.diagramData.title || 'this graph'}.
+ Perfect for sending to a teacher or group partner without exporting files.
+
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.
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.
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.
+`;
+
+ 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{' '}
IB EconGraph AI
- {' '}
- , 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 (they
contain the delete button), so they get role/tabIndex/Enter+Space. The
delete button was also unnamed and stayed invisible under keyboard
focus.
- text-gray-400 body text on white is 2.5:1, below WCAG AA's 4.5:1 for
normal-size text; moved the legal-page date/footer, the shared-view
footer and the comparison-table subtitles to gray-500 (4.8:1).
---
components/AuthModal.tsx | 3 +++
components/ComparePage.tsx | 26 ++++++++++++++++++--------
components/ComponentLibrary.tsx | 20 ++++++++++++++++++--
components/LegalPages.tsx | 6 ++++--
components/SharedViewPage.tsx | 2 +-
5 files changed, 44 insertions(+), 13 deletions(-)
diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx
index 2a55b33..49040c9 100644
--- a/components/AuthModal.tsx
+++ b/components/AuthModal.tsx
@@ -130,6 +130,7 @@ export const AuthModal: React.FC = ({
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@school.org"
+ aria-label="Email address"
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"
/>
@@ -171,6 +172,7 @@ export const AuthModal: React.FC = ({
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@school.org"
+ aria-label="Email address"
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"
/>
@@ -183,6 +185,7 @@ export const AuthModal: React.FC = ({
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isSignup ? `Password (min ${MIN_PASSWORD} characters)` : 'Password'}
+ aria-label="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"
/>
= ({ onOpenEditor, onOpenLanding,
{/* Comparison table */}
-
+ {/* The table is wider than a phone viewport, so the wrapper
+ scrolls horizontally. tabIndex makes that scroll reachable
+ without a pointer; the role/label give the focus stop a name. */}
+
}
{filteredCustom.map((t) => (
+ // Not a : it contains the delete button, and
+ // nesting interactive elements is invalid. role +
+ // tabIndex + a key handler give it the same behaviour.
addCustomTemplate(t)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault(); // Space would scroll the panel
+ addCustomTemplate(t);
+ }
+ }}
>
+ {/* gray-500 rather than gray-400: gray-400 on white is 2.5:1, under
+ WCAG AA's 4.5:1 minimum for normal-size text. */}
+
Last updated: {LAST_UPDATED}
{children}
-
-
diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx
index 49040c9..4d94bd2 100644
--- a/components/AuthModal.tsx
+++ b/components/AuthModal.tsx
@@ -8,6 +8,13 @@ interface AuthModalProps {
onClose: () => void;
title?: string;
message?: string;
+ /**
+ * In-app path to return to after a redirect-based sign-in (Google OAuth, or
+ * the emailed signup confirmation). Defaults to `/settings`. Callers that
+ * gate an action behind sign-in should pass their own page, otherwise the
+ * user lands somewhere they cannot resume from.
+ */
+ returnTo?: string;
}
const MIN_PASSWORD = 8;
@@ -24,6 +31,7 @@ export const AuthModal: React.FC = ({
onClose,
title = 'Sign in',
message,
+ returnTo,
}) => {
const { signInWithPassword, signUpWithPassword, resetPassword, signInWithGoogle } = useAuth();
const [view, setView] = useState('signin');
@@ -64,7 +72,7 @@ export const AuthModal: React.FC = ({
}
setBusy(true);
setError(null);
- const result = await signUpWithPassword(email, password);
+ const result = await signUpWithPassword(email, password, returnTo);
setBusy(false);
if (result.error) setError(result.error);
else if (result.needsConfirmation) setView('confirm-sent');
@@ -84,7 +92,7 @@ export const AuthModal: React.FC = ({
const handleGoogle = async () => {
setError(null);
- const result = await signInWithGoogle();
+ const result = await signInWithGoogle(returnTo);
if (result.error) setError(result.error);
};
diff --git a/components/PricingPage.tsx b/components/PricingPage.tsx
index f00fc9a..ea03e2a 100644
--- a/components/PricingPage.tsx
+++ b/components/PricingPage.tsx
@@ -92,6 +92,9 @@ const PricingPage: React.FC = ({ onOpenEditor, onOpenLanding,
onClose={() => 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."
+ // Come back here, not to Settings: the message above tells them
+ // to click Become a Supporter again, which only exists on this page.
+ returnTo="/pricing"
/>
{/* Nav */}
diff --git a/docs/BACKEND_SETUP.md b/docs/BACKEND_SETUP.md
index 2e2e62b..bd1c070 100644
--- a/docs/BACKEND_SETUP.md
+++ b/docs/BACKEND_SETUP.md
@@ -33,11 +33,14 @@ from the UI — a fork with zero configuration still works perfectly.
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,
+ - **Redirect URLs** → add every origin you sign in from, so confirmation,
+ password-reset and Google OAuth links return to the right page. Use a
+ path wildcard (`https://your-domain/**`): sign-in returns to `/settings`
+ normally, but to `/pricing` when it was triggered from the checkout gate,
+ so a `/settings`-only entry is not enough. 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.
+ back to the Site URL and the link won't land where it should.
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
diff --git a/services/auth.tsx b/services/auth.tsx
index 1bfd7bb..12e4bf3 100644
--- a/services/auth.tsx
+++ b/services/auth.tsx
@@ -34,15 +34,17 @@ interface AuthContextValue {
*/
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 }>;
+ * verification email was sent and no session was established yet.
+ * `returnTo` is the in-app path to land on afterwards (default `/settings`). */
+ signUpWithPassword: (email: string, password: string, returnTo?: 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 }>;
+ /** `returnTo` is the in-app path to land on afterwards (default `/settings`). */
+ signInWithGoogle: (returnTo?: string) => Promise<{ error?: string }>;
signOut: () => Promise;
refreshProfile: () => Promise;
updateProfile: (patch: EditableProfileFields) => Promise<{ error?: string }>;
@@ -73,6 +75,21 @@ export function profileIsPro(profile: Profile | null): boolean {
return isProUntilActive(profile?.pro_until);
}
+/**
+ * Absolute URL for an auth redirect back into the app. Only a same-origin path
+ * is accepted: anything else (a full URL, a protocol-relative `//host` that the
+ * browser would treat as another origin, a backslash variant some parsers
+ * normalise to `/`) falls back to Settings, so a redirect target can never be
+ * pointed off-site.
+ */
+function authRedirectUrl(returnTo?: string): string {
+ const safe =
+ returnTo && /^\/[A-Za-z0-9._~\-/]*$/.test(returnTo) && !returnTo.startsWith('//')
+ ? returnTo
+ : '/settings';
+ return `${window.location.origin}${safe}`;
+}
+
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [session, setSession] = useState(null);
const [profile, setProfile] = useState(null);
@@ -135,12 +152,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
};
}, [fetchProfile]);
- const signUpWithPassword = useCallback(async (email: string, password: string) => {
+ const signUpWithPassword = useCallback(async (email: string, password: string, returnTo?: string) => {
if (!supabase) return NOT_CONFIGURED;
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password,
- options: { emailRedirectTo: `${window.location.origin}/settings` },
+ options: { emailRedirectTo: authRedirectUrl(returnTo) },
});
if (error) return { error: error.message };
// Session present → email confirmation is disabled, user is signed in.
@@ -182,11 +199,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const clearRecoveryMode = useCallback(() => setRecoveryMode(false), []);
- const signInWithGoogle = useCallback(async () => {
+ const signInWithGoogle = useCallback(async (returnTo?: string) => {
if (!supabase) return NOT_CONFIGURED;
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
- options: { redirectTo: `${window.location.origin}/settings` },
+ options: { redirectTo: authRedirectUrl(returnTo) },
});
return error ? { error: error.message } : {};
}, []);
diff --git a/types.ts b/types.ts
index a47ec77..3b0fb55 100644
--- a/types.ts
+++ b/types.ts
@@ -84,6 +84,13 @@ export interface Conversation {
export interface Graph {
id: string;
title: string;
+ /**
+ * Set once the user names the graph themselves (rename dialog, or editing the
+ * title on the canvas). While it is unset the AI is free to retitle the graph
+ * on each generation. Absent on graphs saved before this flag existed, which
+ * fall back to a title-based heuristic.
+ */
+ titleSetByUser?: boolean;
caption: string; // User-editable figure caption
projectId?: string; // Optional: which project this graph belongs to
messages: Message[];
From 31707ab86e9fa907fef6a0a51b7b2893f12976cb Mon Sep 17 00:00:00 2001
From: Sukarth Acharya
Date: Tue, 28 Jul 2026 13:39:09 +0300
Subject: [PATCH 13/29] Fix share snapshot staleness, modal re-query churn,
phone project picker
- Creating a share link published graph.diagramData, which trails the
canvas by the autosave debounce, so a link made straight after an edit
carried the pre-edit diagram. The share now gets the live canvas.
- ShareModal and CloudHistoryModal keyed their load effects on the graph
and user objects. activeGraph is a useMemo over graphs (new identity on
every autosave) and the Supabase User is replaced on each token
refresh, so editing a diagram with either modal open re-issued the
query repeatedly. Keyed on the ids instead, and both now clear their
previous result so a stale share URL or another graph's version list
can't be shown, or restored, against the current graph.
- A shared project on a phone could only ever show its first graph: the
only picker was hidden below the sm breakpoint. Added a compact
horizontally scrolling selector for small screens.
- getAIProvider returned a stored 'hosted' choice even where Supabase is
not configured, leaving generation broken with no visible way to
change it (the option isn't rendered). Falls back to BYOK.
---
App.tsx | 12 ++++++-
components/CloudHistoryModal.tsx | 16 +++++++--
components/ShareModal.tsx | 17 ++++++++--
components/SharedViewPage.tsx | 58 +++++++++++++++++++++++---------
services/aiProvider.ts | 9 ++++-
5 files changed, 88 insertions(+), 24 deletions(-)
diff --git a/App.tsx b/App.tsx
index cf45795..94bee24 100644
--- a/App.tsx
+++ b/App.tsx
@@ -887,6 +887,16 @@ export default function App() {
[graphs, activeGraphId]
);
+ /**
+ * A share stores a snapshot of `graph.diagramData`, but that field trails the
+ * canvas by the autosave debounce. Creating a link straight after an edit
+ * would publish the pre-edit diagram, so hand the share the live canvas.
+ */
+ const shareGraph = useMemo(
+ () => (activeGraph ? { ...activeGraph, diagramData: currentDiagram, title: currentDiagram.title } : null),
+ [activeGraph, currentDiagram]
+ );
+
// 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).
@@ -1403,7 +1413,7 @@ export default function App() {
setShareModalOpen(false)}
- graph={activeGraph}
+ graph={shareGraph}
onOpenSettings={() => navigateToView('settings')}
onOpenPricing={() => navigateToView('pricing')}
/>
diff --git a/components/CloudHistoryModal.tsx b/components/CloudHistoryModal.tsx
index 5114c75..79dbb54 100644
--- a/components/CloudHistoryModal.tsx
+++ b/components/CloudHistoryModal.tsx
@@ -30,15 +30,25 @@ export const CloudHistoryModal: React.FC = ({ isOpen, on
const [versions, setVersions] = useState([]);
const [loading, setLoading] = useState(false);
+ // Keyed on the ids, not the objects: `activeGraph` in App.tsx is a useMemo
+ // over `graphs` and the Supabase User is replaced on every token refresh,
+ // so with the objects in the deps this re-queried the version list while the
+ // user was simply editing the diagram with the modal open.
+ const graphId = graph?.id ?? null;
+ const userId = user?.id ?? null;
useEffect(() => {
- if (!isOpen || !graph || !user || !isPro) return;
+ // Clear first: otherwise the previous graph's snapshots stay listed
+ // until the new query resolves, and restoring one would write another
+ // diagram's content into this graph.
+ setVersions([]);
+ if (!isOpen || !graphId || !userId || !isPro) return;
let cancelled = false;
setLoading(true);
- fetchGraphVersions(graph.id)
+ fetchGraphVersions(graphId)
.then((v) => { if (!cancelled) setVersions(v); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
- }, [isOpen, graph, user, isPro]);
+ }, [isOpen, graphId, userId, isPro]);
const handleRestore = (version: CloudVersion) => {
const data = version.data as Graph | null;
diff --git a/components/ShareModal.tsx b/components/ShareModal.tsx
index 9522482..ecc6732 100644
--- a/components/ShareModal.tsx
+++ b/components/ShareModal.tsx
@@ -30,17 +30,28 @@ export const ShareModal: React.FC = ({ isOpen, onClose, graph,
const [copied, setCopied] = useState(false);
const [error, setError] = useState(null);
+ // Keyed on graph.id, not the graph object: `activeGraph` in App.tsx is a
+ // useMemo over `graphs`, so its identity changes on every autosave. With the
+ // object in the deps, simply editing the diagram with this modal open
+ // re-issued the lookup query on each keystroke-debounce.
+ // (Same for user: the Supabase User object is replaced on every token
+ // refresh, which would re-run this for no reason.)
+ const graphId = graph?.id ?? null;
+ const userId = user?.id ?? null;
useEffect(() => {
- if (!isOpen || !graph || !user || !isPro) return;
+ // Drop any link belonging to a previously inspected graph, so a stale
+ // URL can never be shown for the current one.
+ setShareId(null);
+ if (!isOpen || !graphId || !userId || !isPro) return;
let cancelled = false;
setLoading(true);
setError(null);
setCopied(false);
- getShareIdForGraph(graph.id)
+ getShareIdForGraph(graphId)
.then((id) => { if (!cancelled) setShareId(id); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
- }, [isOpen, graph, user, isPro]);
+ }, [isOpen, graphId, userId, isPro]);
const handleCreate = useCallback(async () => {
if (!graph || !user || creating) return;
diff --git a/components/SharedViewPage.tsx b/components/SharedViewPage.tsx
index 3d6d98d..4c3d44c 100644
--- a/components/SharedViewPage.tsx
+++ b/components/SharedViewPage.tsx
@@ -142,24 +142,50 @@ const SharedViewPage: React.FC = ({ slug, onGoHome }) => {
)}
-
- {active ? (
-
-
- {active.caption && (
-
{active.caption}
- )}
+
+ {payload.kind === 'project' && graphs.length > 1 && (
+ // Phone fallback for the sidebar above, which is
+ // hidden below `sm`. Without it, a shared project
+ // only ever showed its first graph on a phone.
+
>
)}
diff --git a/services/aiProvider.ts b/services/aiProvider.ts
index 1251156..b0169dc 100644
--- a/services/aiProvider.ts
+++ b/services/aiProvider.ts
@@ -1,10 +1,17 @@
+import { isCloudConfigured } from './supabaseClient';
+
export type AIProvider = 'gemini' | 'openrouter' | 'hosted';
const PROVIDER_STORAGE_KEY = 'econgraph_ai_provider';
export function getAIProvider(): AIProvider {
const stored = localStorage.getItem(PROVIDER_STORAGE_KEY);
- if (stored === 'openrouter' || stored === 'hosted') return stored;
+ // 'hosted' needs a cloud backend. If a deployment drops its Supabase
+ // configuration (or a user's storage is carried to a fork that has none),
+ // the stored choice would point at a provider the UI no longer offers and
+ // generation would fail with no way to change it: fall back to BYOK.
+ if (stored === 'hosted') return isCloudConfigured ? 'hosted' : 'gemini';
+ if (stored === 'openrouter') return 'openrouter';
return 'gemini';
}
From ce6139867541b284436797fe5731d62a1fb59b45 Mon Sep 17 00:00:00 2001
From: Sukarth Acharya
Date: Tue, 28 Jul 2026 13:47:32 +0300
Subject: [PATCH 14/29] Enforce last-write-wins in the database; fix sync
tombstone and retry gaps
Schema (re-run supabase/schema.sql):
- The client resolved conflicts by reading every remote row, comparing
last_modified, then upserting. That comparison is only valid at the
instant of the read: two devices, two tabs, or a debounced sync
overlapping a focus sync all read the same baseline, and the slower
push lands last and overwrites newer content with older. Added a
BEFORE UPDATE trigger on graphs and projects that skips a row whose
incoming last_modified is older than the stored one. Verified against
Postgres 16: stale writes dropped, equal and newer writes applied, a
stale tombstone neither deletes the row nor purges its share, a
current tombstone still does both, and a mixed batch drops only the
stale row instead of aborting.
sync.ts:
- The version-dedup hash covered the entire Graph, including
lastModified, which every autosave rewrites. No push ever compared
equal, so a duplicate snapshot was written on every sync, defeating
the optimisation on the fastest-growing table. Hashes diagram content,
title and caption instead.
- A failed graph_versions insert lost that revision for good: the graph
is only re-snapshotted if it is pushed again, which needs a further
edit. Failed ids are now queued and retried.
- A failed share refresh left public links stale forever, because the
next sync only refreshes shares whose source rows it happened to
touch. Failures now set a sticky flag that makes the next run refresh
every share.
- Renaming a project (or recolouring it) never refreshed its share
payload, which embeds the project name: refreshShares only saw graph
rows. It now takes the pushed project rows too.
- Tombstones were only cleared when the remote row was already deleted,
so an id that came back locally kept its tombstone forever. If the
timestamps lined up, the catch-all phase then queued a tombstone for
an id the same batch was upserting as alive, which Postgres rejects
with "ON CONFLICT DO UPDATE command cannot affect row a second time"
and the whole sync failed. Tombstones for locally alive ids are now
dropped before that phase.
useCloudSync.ts:
- The 500ms re-run timer was not stored, so neither unmount nor sign-out
could cancel it and a sync could fire against a signed-out session.
---
services/sync.ts | 144 +++++++++++++++++++++++++++++++++++----
services/useCloudSync.ts | 12 +++-
supabase/schema.sql | 42 ++++++++++++
3 files changed, 182 insertions(+), 16 deletions(-)
diff --git a/services/sync.ts b/services/sync.ts
index 0e6d3b7..1acb4fc 100644
--- a/services/sync.ts
+++ b/services/sync.ts
@@ -37,6 +37,46 @@ function saveVersionHashes(map: Record): void {
} catch { /* quota — best-effort */ }
}
+// Graphs whose snapshot insert failed, and shares whose refresh failed. Both
+// are best-effort steps that run only for rows touched by the current sync, so
+// without a record of the failure a later sync that happens to touch nothing
+// would never retry them and the work would be lost for good.
+const PENDING_VERSIONS_KEY = 'econgraph_pending_versions_v1';
+const PENDING_SHARES_KEY = 'econgraph_pending_share_refresh_v1';
+
+function loadPendingVersionIds(): Set {
+ try {
+ const raw = localStorage.getItem(PENDING_VERSIONS_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) return new Set(parsed.filter((v) => typeof v === 'string'));
+ }
+ } catch { /* corrupted — start fresh */ }
+ return new Set();
+}
+
+function savePendingVersionIds(ids: Set): void {
+ try {
+ if (ids.size === 0) localStorage.removeItem(PENDING_VERSIONS_KEY);
+ else localStorage.setItem(PENDING_VERSIONS_KEY, JSON.stringify([...ids]));
+ } catch { /* quota — best-effort */ }
+}
+
+function sharesRefreshPending(): boolean {
+ try {
+ return localStorage.getItem(PENDING_SHARES_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+
+function setSharesRefreshPending(pending: boolean): void {
+ try {
+ if (pending) localStorage.setItem(PENDING_SHARES_KEY, '1');
+ else localStorage.removeItem(PENDING_SHARES_KEY);
+ } 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 {
@@ -45,6 +85,20 @@ function contentHash(s: string): string {
return h.toString(36);
}
+/**
+ * Fingerprint of the parts a version snapshot exists to preserve. Hashing the
+ * whole `Graph` defeated the dedup entirely: it includes `lastModified`, which
+ * every autosave rewrites, so no push ever compared equal and a duplicate
+ * snapshot was written on each sync.
+ */
+function versionFingerprint(g: Graph): string {
+ return contentHash(JSON.stringify({
+ diagramData: g.diagramData,
+ title: g.title,
+ caption: g.caption,
+ }));
+}
+
interface TombstoneStore {
graphs: Record;
projects: Record;
@@ -334,6 +388,15 @@ export async function syncCloud(userId: string, localGraphsIn: Graph[], localPro
projectRows.push(projectToRow(local, userId));
}
}
+ // A tombstone for an id that is alive locally is stale: the row came back
+ // after the delete was recorded (pulled from another device, restored from
+ // a backup, resurrected above). Left in place it never expires, and if the
+ // timestamps line up the catch-all below queues a tombstone for an id this
+ // same batch is upserting as alive — which Postgres rejects with "ON
+ // CONFLICT DO UPDATE command cannot affect row a second time", failing the
+ // whole sync.
+ for (const id of finalProjects.keys()) delete tombs.projects[id];
+
// 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
@@ -402,6 +465,9 @@ export async function syncCloud(userId: string, localGraphsIn: Graph[], localPro
graphRows.push(graphToRow(local, userId));
}
}
+ // Same stale-tombstone sweep as for projects above.
+ for (const id of finalGraphs.keys()) delete tombs.graphs[id];
+
for (const [id, ts] of Object.entries(tombs.graphs)) {
if (graphTombIds.has(id)) continue; // already queued above
const remote = remoteGraphMap.get(id);
@@ -428,14 +494,27 @@ export async function syncCloud(userId: string, localGraphsIn: Graph[], localPro
saveTombstones(tombs);
// ── Version snapshots for pushed (alive) graphs ──
- if (graphRows.length > 0) {
+ // Retries first: a graph whose snapshot insert failed on an earlier sync is
+ // not necessarily pushed again (it needs no further edits), so without this
+ // its revision would be lost permanently.
+ const pendingVersionIds = loadPendingVersionIds();
+ const versionCandidates = [...graphRows];
+ const queuedIds = new Set(graphRows.map((r) => r.id));
+ for (const id of pendingVersionIds) {
+ if (queuedIds.has(id)) continue;
+ const graph = finalGraphs.get(id);
+ if (graph) versionCandidates.push(graphToRow(graph, userId));
+ else pendingVersionIds.delete(id); // graph is gone; nothing to snapshot
+ }
+
+ if (versionCandidates.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;
+ const changedRows = versionCandidates.filter((row) => {
+ const h = versionFingerprint(row.data as Graph);
+ if (hashes[row.id] === h && !pendingVersionIds.has(row.id)) return false;
hashes[row.id] = h;
return true;
});
@@ -448,7 +527,11 @@ export async function syncCloud(userId: string, localGraphsIn: Graph[], localPro
last_modified: row.last_modified,
}));
const { error } = await supabase.from('graph_versions').insert(versionRows as never[]);
- if (!error) {
+ if (error) {
+ // Queue for the next sync rather than dropping the revision.
+ for (const row of changedRows) pendingVersionIds.add(row.id);
+ } else {
+ for (const row of changedRows) pendingVersionIds.delete(row.id);
saveVersionHashes(hashes);
// Independent per-graph prunes — run them concurrently instead of a
// serial round-trip each, which stalls the debounced sync path.
@@ -460,9 +543,18 @@ export async function syncCloud(userId: string, localGraphsIn: Graph[], localPro
}
}
}
+ savePendingVersionIds(pendingVersionIds);
// ── 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));
+ await refreshShares(
+ userId,
+ finalGraphs,
+ finalProjects,
+ graphRows,
+ projectRows,
+ graphTombRows.map((r) => r.id),
+ projectTombRows.map((r) => r.id),
+ );
return {
graphs: Array.from(finalGraphs.values()),
@@ -478,18 +570,29 @@ async function refreshShares(
finalGraphs: Map,
finalProjects: Map,
pushedGraphRows: RemoteGraphRow[],
+ pushedProjectRows: RemoteProjectRow[],
deletedGraphIds: string[],
deletedProjectIds: string[],
): Promise {
if (!supabase) return;
+ // A previous run failed partway. Its shares were never refreshed and the
+ // rows behind them may not change again, so this run refreshes everything
+ // rather than only what it happened to touch.
+ const retryAll = sharesRefreshPending();
+ let failed = false;
try {
- const { data: shares } = await supabase
+ const { data: shares, error } = await supabase
.from('shares')
.select('id, kind, graph_id, project_id')
.eq('user_id', userId);
- if (!shares || shares.length === 0) return;
+ if (error) throw new Error(error.message);
+ if (!shares || shares.length === 0) {
+ setSharesRefreshPending(false);
+ return;
+ }
const pushedIds = new Set(pushedGraphRows.map((r) => r.id));
+ const pushedProjectIds = new Set(pushedProjectRows.map((r) => r.id));
const allGraphs = Array.from(finalGraphs.values());
// Each share touches a different row, so refresh them concurrently
@@ -497,16 +600,19 @@ async function refreshShares(
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 { error: delErr } = await supabase!.from('shares').delete().eq('id', share.id);
+ if (delErr) failed = true;
+ } else if (retryAll || pushedIds.has(share.graph_id)) {
const graph = finalGraphs.get(share.graph_id)!;
- await supabase!.from('shares')
+ const { error: upErr } = await supabase!.from('shares')
.update({ payload: graphSharePayload(graph), updated_at: new Date().toISOString() })
.eq('id', share.id);
+ if (upErr) failed = true;
}
} 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);
+ const { error: delErr } = await supabase!.from('shares').delete().eq('id', share.id);
+ if (delErr) failed = true;
} else {
const project = finalProjects.get(share.project_id)!;
const memberPushed = allGraphs.some((g) => g.projectId === project.id && pushedIds.has(g.id));
@@ -515,17 +621,25 @@ async function refreshShares(
// 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')
+ // The project row itself can change without any member changing
+ // (a rename, a new colour); the payload embeds the project name,
+ // so that has to re-render too.
+ const projectPushed = pushedProjectIds.has(project.id);
+ if (retryAll || memberPushed || memberDeleted || projectPushed) {
+ const { error: upErr } = await supabase!.from('shares')
.update({ payload: projectSharePayload(project, allGraphs), updated_at: new Date().toISOString() })
.eq('id', share.id);
+ if (upErr) failed = true;
}
}
}
}));
} catch {
- // Share refresh is best-effort; the next sync retries.
+ failed = true;
}
+ // Sticky until a run completes cleanly, so a transient failure cannot leave
+ // a public link showing stale content forever.
+ setSharesRefreshPending(failed);
}
function friendlySyncError(message: string): string {
diff --git a/services/useCloudSync.ts b/services/useCloudSync.ts
index 7b7b647..bdd6cfd 100644
--- a/services/useCloudSync.ts
+++ b/services/useCloudSync.ts
@@ -93,7 +93,15 @@ export function useCloudSync({ userId, hasInitialized, graphs, projects, applyRe
runningRef.current = false;
if (rerunRef.current) {
rerunRef.current = false;
- window.setTimeout(() => { void runSync(); }, 500);
+ // Held in timerRef so unmount and sign-out can cancel it; left
+ // loose, a sync could still fire against a signed-out session.
+ // A later scheduleSync supersedes it, which is correct: that one
+ // runs sooner and reads the same state.
+ if (timerRef.current) window.clearTimeout(timerRef.current);
+ timerRef.current = window.setTimeout(() => {
+ timerRef.current = null;
+ void runSync();
+ }, 500);
}
}
}, []);
@@ -112,6 +120,7 @@ export function useCloudSync({ userId, hasInitialized, graphs, projects, applyRe
if (!userId) {
setSyncState({ status: 'disabled', lastSyncedAt: null, error: null });
if (timerRef.current) window.clearTimeout(timerRef.current);
+ timerRef.current = null;
return;
}
setSyncState((s) => (s.status === 'disabled' ? { ...s, status: 'idle' } : s));
@@ -144,6 +153,7 @@ export function useCloudSync({ userId, hasInitialized, graphs, projects, applyRe
// Cleanup
useEffect(() => () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
+ timerRef.current = null;
}, []);
const syncNow = useCallback(() => {
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 68de282..4993c46 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -490,6 +490,48 @@ create trigger projects_purge_shares_on_delete
after insert or update of deleted or delete on public.projects
for each row execute function public.purge_shares_for_deleted_content();
+-- ----------------------------------------------------------------------------
+-- Last-write-wins, enforced by the database
+-- ----------------------------------------------------------------------------
+-- The client resolves conflicts by reading every remote row, comparing
+-- last_modified, and upserting whatever it decided is newer. That comparison is
+-- only valid at the instant of the read: two devices (or two tabs, or a
+-- debounced sync overlapping a focus sync) both read the same baseline, and the
+-- slower push lands last, overwriting a newer diagram with older content. The
+-- read and the write are not atomic and no amount of client-side care makes
+-- them so.
+--
+-- Doing the comparison inside the write itself closes the window. Returning
+-- null from a BEFORE UPDATE trigger skips that row's update and leaves the
+-- stored row intact, so a stale push is dropped rather than rejected: the
+-- pushing client is not carrying newer data, it just no longer has anything to
+-- contribute, and failing its whole batch would be worse.
+--
+-- Equal timestamps still write, which keeps re-pushing the same row idempotent
+-- and lets a tombstone (whose timestamp is the delete's, and is only ever
+-- queued when it is >= the remote row's) win against the row it deletes.
+create or replace function public.reject_stale_write()
+returns trigger
+language plpgsql
+as $$
+begin
+ if new.last_modified < old.last_modified then
+ return null;
+ end if;
+ return new;
+end;
+$$;
+
+drop trigger if exists graphs_reject_stale_write on public.graphs;
+create trigger graphs_reject_stale_write
+ before update on public.graphs
+ for each row execute function public.reject_stale_write();
+
+drop trigger if exists projects_reject_stale_write on public.projects;
+create trigger projects_reject_stale_write
+ before update on public.projects
+ for each row execute function public.reject_stale_write();
+
-- ----------------------------------------------------------------------------
-- templates — user's custom component templates (synced)
-- ----------------------------------------------------------------------------
From d4f46d429c27ead8f91d5a9fc954bd40cc2243b3 Mon Sep 17 00:00:00 2001
From: Sukarth Acharya
Date: Tue, 28 Jul 2026 13:56:51 +0300
Subject: [PATCH 15/29] Validate model output, add request timeouts, pre-render
SPA route canonicals
Diagram validation (shared by every provider):
- Both /api/generate and the hosted client accepted any object with a
truthy xAxis and yAxis. The renderer reads curves[].points[].x and
divides by the axis range without guarding, so a truncated or partial
model response became NaN geometry or a throw instead of a clear
error. Added diagramShapeError() in services/diagramPrompt.ts, which
checks the required strings, finite and ordered axis bounds, the three
required arrays, and that every curve, region and annotated point
carries finite coordinates.
Request timeouts:
- fetch() has no default timeout, so a server that accepts the
connection and then stalls left the caller's spinner up forever with
no recourse but a reload. Added services/httpTimeout.ts and routed
/api/generate, /api/usage, /api/checkout, /api/portal and
/api/delete-account through it. Generation gets a longer deadline than
the rest, set past the server's own 30s model abort so the server's
message wins whenever there is one.
SEO:
- The sitemap listed /pricing, /compare, /privacy and /terms, but those
were served index.html, whose canonical is hardcoded to "/". The SPA
corrected it after hydration; the served document did not, so the
sitemap was inviting crawlers to index four homepage duplicates. The
build now emits a shell per route: same bundle, correct title,
description, canonical and og:url. Each substitution is asserted so a
future index.html change fails the build instead of silently shipping
the wrong canonical.
Deduplication and correctness:
- ENTITLED_POLAR_STATUSES now lives in services/entitlement.ts instead of
being redeclared in checkout, delete-account and the webhook, where the
three could drift apart.
- openrouter.ts kept its own copy of the system prompt and history
builder. It now shares the rules with the other providers; only the
output instruction differs (no response schema on OpenRouter).
- Sign-up reported "an account with this email already exists", undoing
the ambiguity Supabase deliberately returns and making the endpoint an
account-enumeration oracle. Both cases now get the same confirmation
screen, worded to be true either way.
- The supporters query fetched one page, so past PostgREST's row cap the
newest supporters would silently vanish from the README. Now paged.
---
api/checkout.ts | 5 +-
api/delete-account.ts | 7 ++-
api/generate.ts | 14 +++--
api/webhooks/polar.ts | 4 +-
components/AuthModal.tsx | 5 +-
scripts/generate-seo-pages.mjs | 86 ++++++++++++++++++++++++++++--
scripts/update-supporters.mjs | 36 ++++++++-----
services/auth.tsx | 12 +++--
services/billing.ts | 14 +++--
services/diagramPrompt.ts | 96 +++++++++++++++++++++++++++++++++-
services/entitlement.ts | 16 ++++++
services/hostedAi.ts | 23 +++++---
services/httpTimeout.ts | 44 ++++++++++++++++
services/openrouter.ts | 31 ++---------
14 files changed, 317 insertions(+), 76 deletions(-)
create mode 100644 services/httpTimeout.ts
diff --git a/api/checkout.ts b/api/checkout.ts
index 8ef5aa2..a8f95e3 100644
--- a/api/checkout.ts
+++ b/api/checkout.ts
@@ -1,9 +1,8 @@
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']);
+import { ENTITLED_POLAR_STATUSES } from '../services/entitlement';
/**
* Creates a Polar checkout session for the Supporter plan and returns its URL.
@@ -44,7 +43,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
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)) {
+ if (profile?.polar_subscription_id && isProfilePro(profile) && ENTITLED_POLAR_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',
diff --git a/api/delete-account.ts b/api/delete-account.ts
index dc4c62e..2c8fe4d 100644
--- a/api/delete-account.ts
+++ b/api/delete-account.ts
@@ -1,8 +1,7 @@
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']);
+import { ENTITLED_POLAR_STATUSES } from '../services/entitlement';
/**
* Permanently deletes the signed-in user's account and all cloud data.
@@ -56,7 +55,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
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);
+ if (ENTITLED_POLAR_STATUSES.has(sub.status ?? '')) ids.add(sub.id);
}
}
// Belt and braces: cancel anything our own row knows about too, in case
@@ -83,7 +82,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
let stillActive = true;
try {
const sub = await getPolar().subscriptions.get({ id: subId });
- stillActive = ACTIVE_STATUSES.has(sub.status ?? '');
+ stillActive = ENTITLED_POLAR_STATUSES.has(sub.status ?? '');
} catch (lookupErr) {
// Only a definite "not found" proves the subscription is gone.
// Treating any failure as gone would delete the account during a
diff --git a/api/generate.ts b/api/generate.ts
index 083d327..e91a23b 100644
--- a/api/generate.ts
+++ b/api/generate.ts
@@ -12,6 +12,7 @@ import {
DIAGRAM_SYSTEM_INSTRUCTION,
GEMINI_DIAGRAM_SCHEMA,
buildHistoryContext,
+ diagramShapeError,
} from '../services/diagramPrompt';
const MAX_PROMPT_CHARS = 4000;
@@ -236,11 +237,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
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');
+ // parses to {}. Anything the renderer cannot draw is rejected here: the
+ // response schema makes a malformed object unlikely, not impossible, and
+ // a partial one produces NaN geometry rather than a clear failure. Not
+ // refunded (a produced response counts as used), same rationale as the
+ // parse-failure path.
+ const shapeError = diagramShapeError(diagram);
+ if (shapeError) {
+ console.error(`generate: model returned an unusable diagram (${shapeError})`);
return res.status(502).json({ error: 'The AI returned an empty result. Please try again.' });
}
return res.status(200).json({
diff --git a/api/webhooks/polar.ts b/api/webhooks/polar.ts
index 201e873..0908730 100644
--- a/api/webhooks/polar.ts
+++ b/api/webhooks/polar.ts
@@ -1,6 +1,7 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
import { getSupabaseAdmin } from '../_lib/supabaseAdmin';
+import { ENTITLED_POLAR_STATUSES } from '../../services/entitlement';
// Signature verification requires the raw request body.
export const config = {
@@ -19,7 +20,6 @@ export const config = {
*/
const ACTIVE_MARGIN_DAYS = 1;
-const ENTITLED_STATUSES = new Set(['active', 'trialing', 'past_due']);
function readRawBody(req: VercelRequest): Promise {
return new Promise((resolve, reject) => {
@@ -48,7 +48,7 @@ async function applySubscriptionState(sub: SubscriptionLike): Promise {
}
const admin = getSupabaseAdmin();
- const entitled = ENTITLED_STATUSES.has(sub.status);
+ const entitled = ENTITLED_POLAR_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
diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx
index 4d94bd2..694eed3 100644
--- a/components/AuthModal.tsx
+++ b/components/AuthModal.tsx
@@ -107,8 +107,11 @@ export const AuthModal: React.FC = ({
Check your inbox
+ {/* Worded to be true whether or not the address was already
+ registered: signup deliberately does not reveal which,
+ so this screen must not either. */}
{isConfirm
- ? <>We sent a confirmation link to {email}. Open it to verify your account, then sign in.>
+ ? <>We sent an email to {email}. Open the link in it to verify your account, then sign in. If you already have an account with this address, sign in instead.>
: <>We sent a password-reset link to {email}. Open it to choose a new password.>}
[\s\S]*?<\/title>/, `${esc(route.title)}`],
+ [/]*>/, ``],
+ [//, ``],
+ [//, ``],
+ [//, ``],
+ [//, ``],
+ [//, ``],
+ [//, ``],
+ ];
+ let html = indexHtml;
+ for (const [pattern, replacement] of substitutions) {
+ if (!pattern.test(html)) {
+ console.error(`generate-seo-pages: ${route.file} — no match for ${pattern} in dist/index.html.`);
+ process.exit(1);
+ }
+ html = html.replace(pattern, replacement);
+ }
+ return html;
+}
+
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).
+ // them would submit homepage duplicates. The four content routes below get
+ // a pre-rendered shell each (see SPA_ROUTES), so their served HTML points
+ // at itself rather than the homepage.
const urls = [
{ loc: '/', priority: '1.0', changefreq: 'weekly' },
{ loc: '/pricing', priority: '0.9', changefreq: 'monthly' },
@@ -415,6 +485,14 @@ for (const page of DIAGRAM_PAGES) {
writeFileSync(join(DIST, 'diagrams', `${page.slug}.html`), renderDiagramPage(page));
}
writeFileSync(join(DIST, 'diagrams.html'), renderHubPage());
+
+const indexHtml = readFileSync(join(DIST, 'index.html'), 'utf8');
+for (const route of SPA_ROUTES) {
+ writeFileSync(join(DIST, route.file), renderSpaRouteShell(indexHtml, route));
+}
+
writeFileSync(join(DIST, 'sitemap.xml'), renderSitemap());
-console.log(`Generated ${DIAGRAM_PAGES.length} diagram pages + hub + sitemap.xml into dist/`);
+console.log(
+ `Generated ${DIAGRAM_PAGES.length} diagram pages + hub + ${SPA_ROUTES.length} route shells + sitemap.xml into dist/`,
+);
diff --git a/scripts/update-supporters.mjs b/scripts/update-supporters.mjs
index 598e2fa..b9cf891 100644
--- a/scripts/update-supporters.mjs
+++ b/scripts/update-supporters.mjs
@@ -21,20 +21,32 @@ 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);
+// PostgREST caps a response at `db-max-rows` (1000 by default), so a single
+// query silently drops everyone past the cap: the newest supporters would just
+// stop appearing in the README once the list got long enough. Page until a
+// short page comes back.
+const PAGE_SIZE = 1000;
+const now = new Date().toISOString();
+const data = [];
+for (let from = 0; ; from += PAGE_SIZE) {
+ const { data: page, 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', now)
+ .order('created_at', { ascending: true })
+ .range(from, from + PAGE_SIZE - 1);
+
+ if (error) {
+ console.error('Query failed:', error.message);
+ process.exit(1);
+ }
+ data.push(...(page ?? []));
+ if (!page || page.length < PAGE_SIZE) break;
}
-const names = (data ?? [])
+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.
diff --git a/services/auth.tsx b/services/auth.tsx
index 12e4bf3..66a4366 100644
--- a/services/auth.tsx
+++ b/services/auth.tsx
@@ -162,11 +162,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
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.' };
- }
+ // Supabase deliberately does NOT say whether the address is already
+ // registered: it returns a user with an empty `identities` array instead
+ // of an error, precisely so the endpoint can't be used to enumerate
+ // accounts. Reporting "an account already exists" here would undo that,
+ // so both cases get the identical confirmation screen. Someone who does
+ // own the address learns the truth from the mail they receive; someone
+ // probing addresses learns nothing.
return { needsConfirmation: true };
}, []);
diff --git a/services/billing.ts b/services/billing.ts
index 64ae79b..05e3815 100644
--- a/services/billing.ts
+++ b/services/billing.ts
@@ -1,11 +1,12 @@
import { getAccessToken } from './supabaseClient';
+import { fetchWithTimeout, RequestTimeoutError } from './httpTimeout';
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, {
+ const res = await fetchWithTimeout(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -18,7 +19,8 @@ async function callBillingEndpoint(path: string, body?: unknown): Promise<{ url?
return { error: data?.error || 'Something went wrong. Please try again.' };
}
return { url: data.url };
- } catch {
+ } catch (err) {
+ if (err instanceof RequestTimeoutError) return { error: err.message };
return { error: 'Could not reach the server. Check your connection and try again.' };
}
}
@@ -41,7 +43,7 @@ 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', {
+ const res = await fetchWithTimeout('/api/delete-account', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
@@ -50,7 +52,11 @@ export async function deleteAccount(): Promise<{ error?: string }> {
return { error: data?.error || 'Could not delete your account. Please try again.' };
}
return {};
- } catch {
+ } catch (err) {
+ if (err instanceof RequestTimeoutError) {
+ // Deletion may still be running server-side, so don't imply it failed.
+ return { error: 'The server took too long to respond. Reload and check whether your account was deleted before trying again.' };
+ }
return { error: 'Could not reach the server. Check your connection and try again.' };
}
}
diff --git a/services/diagramPrompt.ts b/services/diagramPrompt.ts
index 5723275..fd74811 100644
--- a/services/diagramPrompt.ts
+++ b/services/diagramPrompt.ts
@@ -3,7 +3,12 @@ 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 = `
+/**
+ * The economics rules every provider shares. Only the closing output
+ * instruction differs between them, so that part is appended per provider
+ * below rather than the whole prompt being copied.
+ */
+const DIAGRAM_RULES = `
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.
@@ -21,10 +26,25 @@ export const DIAGRAM_SYSTEM_INSTRUCTION = `
6. Context:
- If the user asks for "Monopoly", ensure MR is below D.
- If the user asks for "Tax", shift the appropriate curve.
+`;
+/** Gemini (BYOK and hosted): the response schema below enforces the shape. */
+export const DIAGRAM_SYSTEM_INSTRUCTION = `${DIAGRAM_RULES}
Output purely the JSON object matching the schema.
`;
+/**
+ * OpenRouter: an arbitrary model behind a plain chat completion, with no
+ * server-side schema enforcement, so the shape has to be spelled out and prose
+ * and markdown fences explicitly ruled out.
+ */
+export const OPENROUTER_SYSTEM_INSTRUCTION = `${DIAGRAM_RULES}
+ Output requirements (STRICT):
+ - Output ONLY a JSON object (no prose).
+ - Do NOT wrap in markdown.
+ - The JSON must match the DiagramData shape used by this app: { title, summary, xAxis, yAxis, curves, annotatedPoints, shadedRegions }.
+ `;
+
export const GEMINI_DIAGRAM_SCHEMA: Schema = {
type: Type.OBJECT,
properties: {
@@ -123,3 +143,77 @@ export function buildHistoryContext(history: string[]): string {
? `Previous context:\n${history.join("\n")}\n\nCurrent Request:`
: "Request:";
}
+
+/**
+ * Runtime shape check for a model-produced diagram, shared by every provider
+ * (hosted `api/generate`, the hosted client, BYOK Gemini, OpenRouter).
+ *
+ * A response-schema request is a strong hint, not a guarantee: OpenRouter has no
+ * schema at all, and even Gemini can return a truncated or partial object. The
+ * renderer reads `curves[].points[].x` and scales by `xAxis.max - xAxis.min`
+ * without guarding, so a missing array or a non-finite bound throws or produces
+ * NaN geometry rather than a usable error. Checking the axis objects alone (the
+ * previous test) let all of that through.
+ *
+ * Returns null when valid, or a short reason for logging.
+ */
+export function diagramShapeError(value: unknown): string | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "not an object";
+ const d = value as Record;
+
+ for (const key of ["title", "summary"] as const) {
+ if (typeof d[key] !== "string") return `${key} is not a string`;
+ }
+
+ for (const key of ["xAxis", "yAxis"] as const) {
+ const axis = d[key];
+ if (!axis || typeof axis !== "object" || Array.isArray(axis)) return `${key} is missing`;
+ const a = axis as Record;
+ if (typeof a.label !== "string") return `${key}.label is not a string`;
+ if (typeof a.min !== "number" || !Number.isFinite(a.min)) return `${key}.min is not finite`;
+ if (typeof a.max !== "number" || !Number.isFinite(a.max)) return `${key}.max is not finite`;
+ // Equal bounds would make the renderer divide by a zero-width range.
+ if (a.max <= a.min) return `${key}.max is not greater than ${key}.min`;
+ }
+
+ for (const key of ["curves", "annotatedPoints", "shadedRegions"] as const) {
+ if (!Array.isArray(d[key])) return `${key} is not an array`;
+ }
+
+ const hasFinitePoints = (points: unknown): boolean =>
+ Array.isArray(points) &&
+ points.every(
+ (p) =>
+ !!p &&
+ typeof p === "object" &&
+ Number.isFinite((p as { x?: unknown }).x as number) &&
+ Number.isFinite((p as { y?: unknown }).y as number),
+ );
+
+ for (const curve of d.curves as unknown[]) {
+ if (!curve || typeof curve !== "object") return "a curve is not an object";
+ const c = curve as Record;
+ // A curve with no usable geometry renders as nothing at best and throws at
+ // worst, so treat it as a failed generation rather than a blank diagram.
+ if (!hasFinitePoints(c.points) || (c.points as unknown[]).length < 2) {
+ return "a curve has fewer than two finite points";
+ }
+ }
+
+ for (const region of d.shadedRegions as unknown[]) {
+ if (!region || typeof region !== "object") return "a shaded region is not an object";
+ if (!hasFinitePoints((region as Record).points)) {
+ return "a shaded region has non-finite points";
+ }
+ }
+
+ for (const point of d.annotatedPoints as unknown[]) {
+ if (!point || typeof point !== "object") return "an annotated point is not an object";
+ const p = point as Record;
+ if (!Number.isFinite(p.x as number) || !Number.isFinite(p.y as number)) {
+ return "an annotated point has non-finite coordinates";
+ }
+ }
+
+ return null;
+}
diff --git a/services/entitlement.ts b/services/entitlement.ts
index 685a1ce..f382aeb 100644
--- a/services/entitlement.ts
+++ b/services/entitlement.ts
@@ -11,3 +11,19 @@ export function isProUntilActive(proUntil: string | null | undefined): boolean {
if (!proUntil) return false;
return Date.parse(proUntil) > Date.now();
}
+
+/**
+ * Polar subscription statuses that count as a live subscription: the user is
+ * either paying, in a trial, or behind on payment but not yet cancelled. Used
+ * to decide whether to offer a second checkout, whether an account deletion
+ * must revoke first, and whether a webhook grants entitlement.
+ *
+ * Shared so those three answers cannot drift apart. `past_due` is included on
+ * purpose: Polar is still retrying the charge, and dropping access mid-retry
+ * would punish a user whose card simply needs updating.
+ */
+export const ENTITLED_POLAR_STATUSES: ReadonlySet = new Set([
+ 'active',
+ 'trialing',
+ 'past_due',
+]);
diff --git a/services/hostedAi.ts b/services/hostedAi.ts
index 0756381..11f4cff 100644
--- a/services/hostedAi.ts
+++ b/services/hostedAi.ts
@@ -1,5 +1,7 @@
import { DiagramData } from '../types';
import { getAccessToken } from './supabaseClient';
+import { diagramShapeError } from './diagramPrompt';
+import { fetchWithTimeout, GENERATE_TIMEOUT_MS, RequestTimeoutError } from './httpTimeout';
export interface HostedUsage {
used: number;
@@ -20,15 +22,16 @@ export async function generateDiagramDataHosted(prompt: string, history: string[
let res: Response;
try {
- res = await fetch('/api/generate', {
+ res = await fetchWithTimeout('/api/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ prompt, history }),
- });
- } catch {
+ }, GENERATE_TIMEOUT_MS);
+ } catch (err) {
+ if (err instanceof RequestTimeoutError) throw err;
throw new Error('Could not reach the server. Check your connection and try again.');
}
@@ -36,11 +39,17 @@ export async function generateDiagramDataHosted(prompt: string, history: string[
| { 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) {
+ if (!res.ok || !body?.diagram) {
throw new Error(body?.error || 'Hosted AI generation failed. Please try again.');
}
+ // Guard the shape too. The server checks it as well, but this is the last
+ // point before the renderer, which reads curve points and axis bounds
+ // without guarding and turns anything malformed into NaN geometry.
+ const shapeError = diagramShapeError(body.diagram);
+ if (shapeError) {
+ console.error(`hosted AI: unusable diagram (${shapeError})`);
+ throw new Error('The AI returned an unexpected result. Please try again.');
+ }
return body.diagram;
}
@@ -51,7 +60,7 @@ export async function fetchHostedUsage(): Promise {
// show", not reject and leave callers with an unhandled rejection.
const token = await getAccessToken();
if (!token) return null;
- const res = await fetch('/api/usage', {
+ const res = await fetchWithTimeout('/api/usage', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
diff --git a/services/httpTimeout.ts b/services/httpTimeout.ts
new file mode 100644
index 0000000..3172aa9
--- /dev/null
+++ b/services/httpTimeout.ts
@@ -0,0 +1,44 @@
+/**
+ * `fetch()` has no default timeout. A server that accepts the connection and
+ * then stalls leaves the promise pending indefinitely, and with it whatever
+ * spinner the caller is showing: the user's only recourse is reloading the page.
+ * Every first-party API call goes through this wrapper so a hung request
+ * surfaces as an ordinary error the UI can render.
+ */
+
+/** Enough for a normal round trip to a serverless function, including a cold start. */
+export const DEFAULT_TIMEOUT_MS = 20_000;
+
+/**
+ * Diagram generation waits on an upstream model. The server bounds that at 30s
+ * and the platform kills the function at 60s (`maxDuration` in vercel.json), so
+ * this sits just past both: the server's own error message wins whenever there
+ * is one, and this only fires if the connection itself has died.
+ */
+export const GENERATE_TIMEOUT_MS = 65_000;
+
+export class RequestTimeoutError extends Error {
+ constructor(message = 'The server took too long to respond. Please try again.') {
+ super(message);
+ this.name = 'RequestTimeoutError';
+ }
+}
+
+export async function fetchWithTimeout(
+ input: RequestInfo | URL,
+ init: RequestInit = {},
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
+): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ return await fetch(input, { ...init, signal: controller.signal });
+ } catch (err) {
+ // Distinguish our own deadline from a network failure or a caller's
+ // abort, so the message can say which one happened.
+ if (controller.signal.aborted) throw new RequestTimeoutError();
+ throw err;
+ } finally {
+ clearTimeout(timer);
+ }
+}
diff --git a/services/openrouter.ts b/services/openrouter.ts
index 0f87514..55e0a81 100644
--- a/services/openrouter.ts
+++ b/services/openrouter.ts
@@ -1,5 +1,6 @@
import { DiagramData } from '../types';
import { obfuscateKey, deobfuscateKey } from './keyObfuscation';
+import { OPENROUTER_SYSTEM_INSTRUCTION, buildHistoryContext } from './diagramPrompt';
const STORAGE_KEY = 'econgraph_openrouter_api_key';
const MODEL_STORAGE_KEY = 'econgraph_openrouter_selected_model';
@@ -252,34 +253,8 @@ export async function generateDiagramDataOpenRouter(prompt: string, history: str
throw new Error('No OpenRouter model selected. Please choose a model in Settings before using OpenRouter.');
}
- 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 requirements (STRICT):
-- Output ONLY a JSON object (no prose).
-- Do NOT wrap in markdown.
-- The JSON must match the DiagramData shape used by this app: { title, summary, xAxis, yAxis, curves, annotatedPoints, shadedRegions }.
-`;
+ const historyContext = buildHistoryContext(history);
+ const systemInstruction = OPENROUTER_SYSTEM_INSTRUCTION;
const baseBody: any = {
model,
From ba2dc1a87d625799113c78c0ea62208ee8f3b074 Mon Sep 17 00:00:00 2001
From: Sukarth Acharya
Date: Tue, 28 Jul 2026 14:02:50 +0300
Subject: [PATCH 16/29] Order Polar webhook events and block concurrent
checkouts
Webhook (re-run supabase/schema.sql for the new profiles.polar_event_at):
- A delayed subscription.active or subscription.updated for the SAME
subscription, delivered after a cancellation, restored entitlement:
the only staleness test was "is this a different subscription id", so
the event passed every guard and Math.max() pushed pro_until back into
the future. Events now carry their own order: the Polar modifiedAt
(falling back to createdAt) is stored on the profile, and an event
older than the last one applied is dropped.
- The read-compute-write in applySubscriptionState was three separate
round trips, so two concurrent deliveries for one user could each act
on the same snapshot and the slower write would win regardless of
which event was newer. The ordering test is now also a predicate on
the UPDATE, making the decision atomic; an overtaken handler matches
no row and writes nothing. Verified the supabase-js .eq().or() pair
compiles to id = X AND (polar_event_at IS NULL OR polar_event_at <= T)
rather than ORing the id filter away.
Checkout:
- The double-charge guard read our own profile row, which is only as
fresh as the last webhook processed, and the webhook lands seconds
after payment succeeds. In that window a second tab or a double click
passed the check and could create a second payable subscription. Polar
knows about the first subscription the moment it is paid, so checkout
now asks Polar directly, the same authority argument delete-account
already uses, and refuses when the billing state can't be verified.
Schema verified against Postgres 16: applies twice cleanly, and adds the
new column to an install created before it existed.
---
api/checkout.ts | 37 ++++++++++++++++++++++++-
api/webhooks/polar.ts | 63 +++++++++++++++++++++++++++++++++++++++++--
supabase/schema.sql | 9 +++++++
3 files changed, 106 insertions(+), 3 deletions(-)
diff --git a/api/checkout.ts b/api/checkout.ts
index a8f95e3..7144240 100644
--- a/api/checkout.ts
+++ b/api/checkout.ts
@@ -50,8 +50,43 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
});
}
+ let polar;
+ try {
+ polar = getPolar();
+ } catch (err) {
+ console.error('checkout: Polar is not configured', err);
+ return res.status(503).json({ error: 'Billing is not configured on this deployment.' });
+ }
+
+ // The profile check above is only as fresh as the last webhook we processed,
+ // and the webhook lands seconds *after* payment succeeds. In that window a
+ // user who double-clicks, or opens checkout in a second tab, passes the
+ // check and can pay twice, ending up with two parallel subscriptions.
+ // Polar knows about the first subscription as soon as it is paid, so ask it
+ // rather than trusting our own copy. Same authority argument as
+ // delete-account.
+ try {
+ const page = await polar.subscriptions.list({ externalCustomerId: user.id, active: true });
+ for await (const chunk of page) {
+ for (const sub of chunk.result.items) {
+ if (ENTITLED_POLAR_STATUSES.has(sub.status ?? '')) {
+ return res.status(409).json({
+ error: 'You already have an active Supporter subscription. Manage it from Settings > Manage billing.',
+ code: 'already_subscribed',
+ });
+ }
+ }
+ }
+ } catch (err) {
+ // Refuse rather than risk a duplicate charge: an unverifiable billing
+ // state is exactly when a second checkout is most dangerous.
+ console.error('checkout: could not list existing subscriptions', err);
+ return res.status(503).json({
+ error: 'Could not verify your billing status right now. Please try again in a moment.',
+ });
+ }
+
try {
- const polar = getPolar();
const appUrl = getAppUrl(req);
const checkout = await polar.checkouts.create({
products: [productId],
diff --git a/api/webhooks/polar.ts b/api/webhooks/polar.ts
index 0908730..886aaba 100644
--- a/api/webhooks/polar.ts
+++ b/api/webhooks/polar.ts
@@ -37,6 +37,30 @@ interface SubscriptionLike {
recurringInterval?: string | null;
customerId?: string;
customer?: { id?: string; externalId?: string | null } | null;
+ /** When Polar last changed this subscription. Used to order deliveries. */
+ modifiedAt?: Date | null;
+ createdAt?: Date | null;
+}
+
+/**
+ * Ordering key for an event. Webhook deliveries are not ordered and are
+ * retried, so "the event that arrived last" is not "the event that happened
+ * last". Polar stamps every subscription change with `modifiedAt`; a freshly
+ * created subscription has none yet, so `createdAt` stands in.
+ *
+ * Returns null when neither is usable, in which case the caller falls back to
+ * applying the event unordered (better than dropping billing state entirely).
+ */
+function eventTimestamp(sub: SubscriptionLike): Date | null {
+ for (const candidate of [sub.modifiedAt, sub.createdAt]) {
+ if (candidate instanceof Date && !Number.isNaN(candidate.getTime())) return candidate;
+ // The SDK parses these into Dates, but a hand-built payload may carry strings.
+ if (typeof candidate === 'string') {
+ const parsed = new Date(candidate);
+ if (!Number.isNaN(parsed.getTime())) return parsed;
+ }
+ }
+ return null;
}
async function applySubscriptionState(sub: SubscriptionLike): Promise {
@@ -55,7 +79,7 @@ async function applySubscriptionState(sub: SubscriptionLike): Promise {
// (e.g. after cancel + resubscribe, a delayed event for the old sub).
const { data: current, error: currentError } = await admin
.from('profiles')
- .select('polar_subscription_id, pro_until')
+ .select('polar_subscription_id, pro_until, polar_event_at')
.eq('id', userId)
.maybeSingle();
if (currentError) {
@@ -69,6 +93,22 @@ async function applySubscriptionState(sub: SubscriptionLike): Promise {
const DAY_MS = 24 * 60 * 60 * 1000;
const currentEnd = current?.pro_until ? Date.parse(current.pro_until) : 0;
+ // Deliveries are neither ordered nor deduplicated. Checking only that the
+ // subscription id matches (as this used to) left the worst case open: a
+ // delayed `subscription.active` for the SAME subscription, arriving after a
+ // cancellation, passed every guard and the `Math.max` below then restored
+ // the future pro_until. Comparing the event's own timestamp against the
+ // last one applied rejects it.
+ const eventAt = eventTimestamp(sub);
+ const appliedAt = current?.polar_event_at ? Date.parse(current.polar_event_at) : null;
+ if (eventAt && appliedAt !== null && eventAt.getTime() < appliedAt) {
+ console.log(
+ `polar webhook: ignoring ${sub.status} for ${sub.id}; event is older ` +
+ `(${eventAt.toISOString()}) than the last applied (${current!.polar_event_at})`,
+ );
+ return;
+ }
+
let proUntil: string;
if (entitled) {
const hasPeriodEnd = sub.currentPeriodEnd instanceof Date && !Number.isNaN(sub.currentPeriodEnd.getTime());
@@ -103,7 +143,14 @@ async function applySubscriptionState(sub: SubscriptionLike): Promise {
proUntil = new Date().toISOString();
}
- const { error } = await admin
+ // The read above and this write are separate round trips, so two concurrent
+ // deliveries for the same user can each compute from the same snapshot and
+ // the slower write wins regardless of which event is newer. Repeating the
+ // ordering test as a predicate on the UPDATE makes the decision atomic: a
+ // handler whose event has been overtaken matches no row and writes nothing.
+ // `lte` rather than `lt` so a retry of the very same event is idempotent.
+ const eventAtIso = eventAt ? eventAt.toISOString() : null;
+ let query = admin
.from('profiles')
.update({
pro_status: sub.status,
@@ -111,14 +158,26 @@ async function applySubscriptionState(sub: SubscriptionLike): Promise {
plan_interval: sub.recurringInterval ?? null,
polar_customer_id: sub.customer?.id ?? sub.customerId ?? null,
polar_subscription_id: sub.id,
+ polar_event_at: eventAtIso,
updated_at: new Date().toISOString(),
})
.eq('id', userId);
+ if (eventAtIso) {
+ query = query.or(`polar_event_at.is.null,polar_event_at.lte.${eventAtIso}`);
+ }
+ // `select` so a zero-row result is distinguishable from a successful write.
+ const { data: updated, error } = await query.select('id');
if (error) {
// Throw so Polar retries the delivery.
throw new Error(`Failed to update profile ${userId}: ${error.message}`);
}
+ if (!updated || updated.length === 0) {
+ // Either the profile row is gone (deleted account) or a newer event won
+ // the race. Neither is retryable, so ack rather than throwing.
+ console.log(`polar webhook: no row updated for ${userId} (${sub.id}); a newer event or a deleted account`);
+ return;
+ }
console.log(`polar webhook: ${userId} → status=${sub.status} pro_until=${proUntil}`);
}
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 4993c46..11beba9 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -37,10 +37,19 @@ create table if not exists public.profiles (
plan_interval text,
polar_customer_id text,
polar_subscription_id text,
+ -- `modifiedAt` of the last Polar subscription event applied to this row. The
+ -- webhook refuses to apply an event older than this, which is what stops a
+ -- delayed `subscription.active` delivered after a cancellation from handing
+ -- entitlement back.
+ polar_event_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
+-- Added after the initial release; `create table if not exists` above skips
+-- existing installs, so bring them forward explicitly.
+alter table public.profiles add column if not exists polar_event_at timestamptz;
+
alter table public.profiles enable row level security;
-- Create a profile row automatically for every new auth user.
From a0e3d661d64690524e4345ba66681d9493d7f99e Mon Sep 17 00:00:00 2001
From: Sukarth Acharya
Date: Tue, 28 Jul 2026 14:11:51 +0300
Subject: [PATCH 17/29] Drop the renewal margin on scheduled cancellations; SPA
footer links
- ACTIVE_MARGIN_DAYS exists to cover the gap before a renewal webhook
lands. Polar leaves a subscription 'active' after the user schedules a
cancellation, so that margin was still being added to a subscription
that will never renew, granting an extra day nobody paid for. When
cancelAtPeriodEnd is set (or endsAt is present) the margin is dropped
and endsAt becomes the authoritative end date, including when it moves
access earlier. Access through the already-paid period is unchanged:
that part is deliberate, not a bug.
- Extracted the decision into a pure exported decideEntitlement() so the
ordering and entitlement rules can be tested directly. 23 cases pass
against the real function, covering the delayed-active-after-revoke
regression, resubscribe, exact-redelivery idempotency, scheduled
cancellation, a shortened endsAt, past_due, and malformed events.
- Landing-page Privacy/Terms were hard links that reloaded the whole
bundle while every neighbouring footer entry used SPA navigation. They
stay real anchors (crawlable, middle-clickable, hover shows the URL)
but a plain left click now routes in-app.
- Remaining text-gray-400 body text on the landing page raised to
gray-500, same WCAG AA contrast fix already applied elsewhere.
---
App.tsx | 2 +
api/webhooks/polar.ts | 148 ++++++++++++++++++++++++++-----------
components/LandingPage.tsx | 56 +++++++++-----
3 files changed, 141 insertions(+), 65 deletions(-)
diff --git a/App.tsx b/App.tsx
index 94bee24..e035b84 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1313,6 +1313,8 @@ export default function App() {
onGoHome={() => navigateToView('home')}
onOpenPricing={() => navigateToView('pricing')}
onOpenCompare={() => navigateToView('compare')}
+ onOpenPrivacy={() => navigateToView('privacy')}
+ onOpenTerms={() => navigateToView('terms')}
/>
);
}
diff --git a/api/webhooks/polar.ts b/api/webhooks/polar.ts
index 886aaba..2d5afce 100644
--- a/api/webhooks/polar.ts
+++ b/api/webhooks/polar.ts
@@ -40,6 +40,19 @@ interface SubscriptionLike {
/** When Polar last changed this subscription. Used to order deliveries. */
modifiedAt?: Date | null;
createdAt?: Date | null;
+ /** Set when the user has cancelled but keeps access to the end of the paid period. */
+ cancelAtPeriodEnd?: boolean | null;
+ /** The definitive end of access once cancellation is scheduled. */
+ endsAt?: Date | null;
+}
+
+function toDate(value: unknown): Date | null {
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
+ if (typeof value === 'string') {
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+ }
+ return null;
}
/**
@@ -63,31 +76,30 @@ function eventTimestamp(sub: SubscriptionLike): Date | null {
return 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;
- }
+/** What the profile row currently says about this user's billing. */
+export interface CurrentBillingState {
+ polar_subscription_id?: string | null;
+ pro_until?: string | null;
+ polar_event_at?: string | null;
+}
- const admin = getSupabaseAdmin();
- const entitled = ENTITLED_POLAR_STATUSES.has(sub.status);
+export type EntitlementDecision =
+ | { action: 'skip'; reason: string }
+ | { action: 'apply'; proUntil: string; eventAt: string | null };
- // 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, error: currentError } = await admin
- .from('profiles')
- .select('polar_subscription_id, pro_until, polar_event_at')
- .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}`);
- }
+/**
+ * Decide what an incoming subscription event should do to a profile. Pure, so
+ * the ordering and entitlement rules below can be exercised directly instead of
+ * only through a live webhook against real billing.
+ *
+ * `now` is injected for the same reason.
+ */
+export function decideEntitlement(
+ sub: SubscriptionLike,
+ current: CurrentBillingState | null,
+ now: number = Date.now(),
+): EntitlementDecision {
+ const entitled = ENTITLED_POLAR_STATUSES.has(sub.status);
const onFile = current?.polar_subscription_id;
const differentSub = !!onFile && onFile !== sub.id;
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -102,54 +114,100 @@ async function applySubscriptionState(sub: SubscriptionLike): Promise {
const eventAt = eventTimestamp(sub);
const appliedAt = current?.polar_event_at ? Date.parse(current.polar_event_at) : null;
if (eventAt && appliedAt !== null && eventAt.getTime() < appliedAt) {
- console.log(
- `polar webhook: ignoring ${sub.status} for ${sub.id}; event is older ` +
- `(${eventAt.toISOString()}) than the last applied (${current!.polar_event_at})`,
- );
- return;
+ return {
+ action: 'skip',
+ reason: `event for ${sub.id} is older (${eventAt.toISOString()}) than the last applied (${current!.polar_event_at})`,
+ };
}
+ const eventAtIso = eventAt ? eventAt.toISOString() : null;
let proUntil: string;
if (entitled) {
- const hasPeriodEnd = sub.currentPeriodEnd instanceof Date && !Number.isNaN(sub.currentPeriodEnd.getTime());
+ // Polar keeps a subscription `active` after the user schedules a
+ // cancellation; it just stops renewing. Access through the period they
+ // already paid for is correct and deliberate, but `endsAt` is then the
+ // authoritative end date, and the renewal margin must not apply: that
+ // margin exists to cover the gap before a *renewal* webhook lands, and
+ // a subscription that will not renew has no such gap. Adding it would
+ // hand out a day of access nobody paid for.
+ const endsAt = toDate(sub.endsAt);
+ const scheduledToEnd = sub.cancelAtPeriodEnd === true || !!endsAt;
+ const periodEnd = endsAt ?? toDate(sub.currentPeriodEnd);
+
// 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`);
- }
+ const candidate = periodEnd
+ ? periodEnd.getTime() + (scheduledToEnd ? 0 : ACTIVE_MARGIN_DAYS * DAY_MS)
+ : now + 2 * DAY_MS;
// 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;
+ return {
+ action: 'skip',
+ reason: `stale entitled event for ${sub.id}; ${onFile} on file runs at least as long`,
+ };
}
- // 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();
+ // Normally never move a still-entitled user's access backward. A
+ // scheduled cancellation is the exception: it legitimately shortens
+ // access (dropping the margin, or moving to an earlier endsAt), and the
+ // event-ordering check above already rejects genuinely stale deliveries,
+ // which is what this guard used to be protecting against.
+ proUntil = new Date(scheduledToEnd ? candidate : 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;
+ return {
+ action: 'skip',
+ reason: `${sub.status} for stale subscription ${sub.id} (current is ${onFile})`,
+ };
}
- proUntil = new Date().toISOString();
+ proUntil = new Date(now).toISOString();
+ }
+
+ return { action: 'apply', proUntil, eventAt: eventAtIso };
+}
+
+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();
+
+ // Read what's currently on file so out-of-order or superseded events can't
+ // clobber the state the user is actually in.
+ const { data: current, error: currentError } = await admin
+ .from('profiles')
+ .select('polar_subscription_id, pro_until, polar_event_at')
+ .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 decision = decideEntitlement(sub, current as CurrentBillingState | null);
+ if (decision.action === 'skip') {
+ console.log(`polar webhook: ignoring ${decision.reason}`);
+ return;
+ }
+ const { proUntil, eventAt: eventAtIso } = decision;
+
// The read above and this write are separate round trips, so two concurrent
// deliveries for the same user can each compute from the same snapshot and
// the slower write wins regardless of which event is newer. Repeating the
// ordering test as a predicate on the UPDATE makes the decision atomic: a
// handler whose event has been overtaken matches no row and writes nothing.
// `lte` rather than `lt` so a retry of the very same event is idempotent.
- const eventAtIso = eventAt ? eventAt.toISOString() : null;
let query = admin
.from('profiles')
.update({
diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx
index 931ca0d..d5b1934 100644
--- a/components/LandingPage.tsx
+++ b/components/LandingPage.tsx
@@ -22,8 +22,34 @@ export interface LandingPageProps {
onGoHome: () => void;
onOpenPricing: () => void;
onOpenCompare: () => void;
+ onOpenPrivacy: () => void;
+ onOpenTerms: () => void;
}
+/**
+ * Footer legal link. Stays a real `` so it is crawlable, middle-clickable
+ * and shows its target on hover, but a plain left click routes through the SPA
+ * instead of reloading the whole bundle. Modifier clicks are left to the
+ * browser.
+ */
+const RouteLink: React.FC<{ href: string; onNavigate: () => void; children: React.ReactNode }> = ({
+ href,
+ onNavigate,
+ children,
+}) => (
+ {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
+ e.preventDefault();
+ onNavigate();
+ }}
+ className="text-sm text-gray-500 hover:text-gray-900 transition-colors"
+ >
+ {children}
+
+);
+
// ─── Fade-in on scroll component ───
const ScrollReveal: React.FC<{
children: React.ReactNode;
@@ -73,7 +99,7 @@ const ScrollReveal: React.FC<{
);
};
-const LandingPage: React.FC = ({ onGoHome, onOpenPricing, onOpenCompare }) => {
+const LandingPage: React.FC = ({ onGoHome, onOpenPricing, onOpenCompare, onOpenPrivacy, onOpenTerms }) => {
const [scrollY, setScrollY] = useState(0);
const [heroTilt, setHeroTilt] = useState({ rotateX: 0, rotateY: 0 });
const [openSourceMouse, setOpenSourceMouse] = useState({ x: 50, y: 50 });
@@ -269,7 +295,7 @@ const LandingPage: React.FC = ({ onGoHome, onOpenPricing, onOp
-
+
Everything a student needs to finish their IA is free and unlimited, forever.
From 7e645a0da57c6027ddc7704a97b5faebe9f15711 Mon Sep 17 00:00:00 2001
From: Sukarth Acharya
Date: Tue, 28 Jul 2026 14:14:39 +0300
Subject: [PATCH 18/29] Disclose per-backend AI data handling in the privacy
policy
The policy said we "do not use your prompts to train any model", which
is true of us but reads as a claim about the whole pipeline. The three
hosted-AI backends are not equivalent: Google's paid Cloud/Vertex
endpoints are covered by terms that exclude training on customer
content, while the free Gemini developer tier permits Google to review
content and use it to improve its services. A deployment on the free
tier was therefore under-disclosing. The clause now states what we do
and that Google's own terms apply, and differ by backend, and points at
BYOK for anyone who would rather nothing leave their browser.
BACKEND_SETUP now flags the same thing where the backend is chosen, so a
self-hoster sees the consequence before picking Option C.
---
components/LegalPages.tsx | 8 +++++++-
docs/BACKEND_SETUP.md | 9 +++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/components/LegalPages.tsx b/components/LegalPages.tsx
index 40c56e0..7502db9 100644
--- a/components/LegalPages.tsx
+++ b/components/LegalPages.tsx
@@ -80,7 +80,13 @@ export const PrivacyPage: React.FC = () => (
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.
+ number of generations per month and never use your prompts to train a model. What Google
+ does with them is governed by its own terms for whichever Google API this deployment is
+ configured to use: its paid Google Cloud (Vertex AI) endpoints are covered by terms that
+ exclude training on customer content, while its free developer tier permits Google to review
+ content and use it to improve its services. If you would rather not rely on that, use your
+ own API key: those requests go straight from your browser to your provider and never reach
+ us.
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.
diff --git a/docs/BACKEND_SETUP.md b/docs/BACKEND_SETUP.md
index bd1c070..03d87e0 100644
--- a/docs/BACKEND_SETUP.md
+++ b/docs/BACKEND_SETUP.md
@@ -142,6 +142,15 @@ 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.
+> **Privacy consequence of this choice.** The three backends are not equivalent
+> for user data. Google's paid Cloud/Vertex endpoints (A and B) are covered by
+> terms that exclude training on customer content; the free Gemini developer
+> tier (C) permits Google to review content and use it to improve its services.
+> The privacy policy in `components/LegalPages.tsx` describes both cases, so it
+> stays accurate whichever you pick, but if you run a public deployment on
+> Option C your users' prompts are handled under the free-tier terms. Prefer A
+> or B for anything beyond personal or testing use.
+
## 3. Polar (billing)
1. Create an organization at [polar.sh](https://polar.sh)
From 4cffab0a7d993a3162945d9edba5cd27a2388b77 Mon Sep 17 00:00:00 2001
From: Sukarth Acharya
Date: Tue, 28 Jul 2026 17:04:39 +0300
Subject: [PATCH 19/29] Add GoatCounter; revert privacy and changelog edits;
drop the checkout Polar call
Analytics:
- GoatCounter on index.html (covers the SPA and the four pre-rendered
route shells) and on the generated /diagrams pages, which are
standalone HTML and need their own tag.
- Share URLs are /s/<96-bit slug>, and that slug is a capability token:
anyone holding it can read the diagram. GoatCounter's auto-count sends
location.pathname, so a shared link would have handed the token to a
third party. A path hook installed before count.js strips it to /s/.
- The SPA counts its own route changes, skipping the first view since
the tag already counted the load.
Reverts:
- Privacy policy hosted-AI clause restored. The rewrite was motivated by
protecting people re-hosting the fork on the free Google AI Studio
tier, which is not this project's concern.
- CHANGELOG version links restored.
checkout.ts:
- Removed the polar.subscriptions.list() pre-check. It cost a Polar API
call on every attempt at an endpoint any signed-in user can hit
repeatedly, and to be worth anything it had to fail closed, so a Polar
rate-limit or outage would have blocked all new subscriptions. It also
did not actually cover the case it was added for: in the window
between payment and webhook the profile has no billing data, which is
precisely why the cheap check misses it. Double-clicks are already
handled client-side.
---
App.tsx | 20 ++++++++++++++++++
CHANGELOG.md | 7 ++-----
api/checkout.ts | 38 +++++++++-------------------------
components/LegalPages.tsx | 8 +------
docs/BACKEND_SETUP.md | 9 --------
index.html | 17 +++++++++++++++
scripts/generate-seo-pages.mjs | 6 ++++++
7 files changed, 56 insertions(+), 49 deletions(-)
diff --git a/App.tsx b/App.tsx
index e035b84..e8fe985 100644
--- a/App.tsx
+++ b/App.tsx
@@ -517,6 +517,7 @@ export default function App() {
// 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.
+ const countedFirstViewRef = useRef(false);
useEffect(() => {
const SITE = 'https://ib-econgraph-ai.vercel.app';
const meta: Record = {
@@ -536,6 +537,25 @@ export default function App() {
document.head.appendChild(link);
}
link.href = SITE + entry.path;
+
+ // GoatCounter counts the first load itself (the tag in index.html), so only
+ // report navigations after that, otherwise every visit double-counts its
+ // entry page. Views with no metadata entry are app UI rather than content;
+ // report the real path for those, minus any share slug, which is a
+ // capability token and must not reach an analytics endpoint.
+ if (!countedFirstViewRef.current) {
+ countedFirstViewRef.current = true;
+ return;
+ }
+ const countedPath = meta[view]
+ ? entry.path
+ : window.location.pathname.replace(/^\/s\/[^/]+\/?$/, '/s/');
+ // Optional chaining throughout: the tag is `async`, so on a fast navigation
+ // it may not have loaded yet. A missed count is fine; a crash is not.
+ const gc = (window as unknown as {
+ goatcounter?: { count?: (opts: { path: string; title: string }) => void };
+ }).goatcounter;
+ gc?.count?.({ path: countedPath, title: entry.title });
}, [view]);
// Scroll the chat to the bottom when a message is added to the open graph (or
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3187838..96a2ce1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -90,8 +90,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/api/checkout.ts b/api/checkout.ts
index 7144240..3bcb05d 100644
--- a/api/checkout.ts
+++ b/api/checkout.ts
@@ -58,34 +58,16 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
return res.status(503).json({ error: 'Billing is not configured on this deployment.' });
}
- // The profile check above is only as fresh as the last webhook we processed,
- // and the webhook lands seconds *after* payment succeeds. In that window a
- // user who double-clicks, or opens checkout in a second tab, passes the
- // check and can pay twice, ending up with two parallel subscriptions.
- // Polar knows about the first subscription as soon as it is paid, so ask it
- // rather than trusting our own copy. Same authority argument as
- // delete-account.
- try {
- const page = await polar.subscriptions.list({ externalCustomerId: user.id, active: true });
- for await (const chunk of page) {
- for (const sub of chunk.result.items) {
- if (ENTITLED_POLAR_STATUSES.has(sub.status ?? '')) {
- return res.status(409).json({
- error: 'You already have an active Supporter subscription. Manage it from Settings > Manage billing.',
- code: 'already_subscribed',
- });
- }
- }
- }
- } catch (err) {
- // Refuse rather than risk a duplicate charge: an unverifiable billing
- // state is exactly when a second checkout is most dangerous.
- console.error('checkout: could not list existing subscriptions', err);
- return res.status(503).json({
- error: 'Could not verify your billing status right now. Please try again in a moment.',
- });
- }
-
+ // Deliberately NOT calling polar.subscriptions.list() here to close the gap
+ // between a payment succeeding and its webhook landing. That check costs a
+ // Polar API call on every checkout attempt, on an endpoint any signed-in
+ // user can hit repeatedly, and it has to fail closed to be worth anything —
+ // so a Polar rate-limit or outage would stop all new subscriptions. It also
+ // would not help in the case it was meant for: during that window the
+ // profile has no billing data yet, which is exactly why the check above
+ // misses it. The double-click path is already handled client-side (the
+ // subscribe button disables while a checkout is in flight), leaving only a
+ // deliberate pay-twice-in-two-tabs case, which is refundable in Polar.
try {
const appUrl = getAppUrl(req);
const checkout = await polar.checkouts.create({
diff --git a/components/LegalPages.tsx b/components/LegalPages.tsx
index 7502db9..40c56e0 100644
--- a/components/LegalPages.tsx
+++ b/components/LegalPages.tsx
@@ -80,13 +80,7 @@ export const PrivacyPage: React.FC = () => (
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 and never use your prompts to train a model. What Google
- does with them is governed by its own terms for whichever Google API this deployment is
- configured to use: its paid Google Cloud (Vertex AI) endpoints are covered by terms that
- exclude training on customer content, while its free developer tier permits Google to review
- content and use it to improve its services. If you would rather not rely on that, use your
- own API key: those requests go straight from your browser to your provider and never reach
- us.
+ 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.
diff --git a/docs/BACKEND_SETUP.md b/docs/BACKEND_SETUP.md
index 03d87e0..bd1c070 100644
--- a/docs/BACKEND_SETUP.md
+++ b/docs/BACKEND_SETUP.md
@@ -142,15 +142,6 @@ 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.
-> **Privacy consequence of this choice.** The three backends are not equivalent
-> for user data. Google's paid Cloud/Vertex endpoints (A and B) are covered by
-> terms that exclude training on customer content; the free Gemini developer
-> tier (C) permits Google to review content and use it to improve its services.
-> The privacy policy in `components/LegalPages.tsx` describes both cases, so it
-> stays accurate whichever you pick, but if you run a public deployment on
-> Option C your users' prompts are handled under the free-tier terms. Prefer A
-> or B for anything beyond personal or testing use.
-
## 3. Polar (billing)
1. Create an organization at [polar.sh](https://polar.sh)
diff --git a/index.html b/index.html
index beb990f..9a3dc15 100644
--- a/index.html
+++ b/index.html
@@ -54,6 +54,23 @@
}
+
+
+
+
From d5077971f08586e748d44969a8cf52532529ebde Mon Sep 17 00:00:00 2001
From: Sukarth Acharya
Date: Tue, 28 Jul 2026 17:19:27 +0300
Subject: [PATCH 20/29] Fetch diagram payloads only for the rows sync actually
pulls
The reconciliation query selected `data` for every graph the user owns, so
each sync downloaded the entire library just to compare timestamps, and
then threw almost all of it away. An 80-diagram library cost ~3.2 MB per
sync even when nothing had changed.
Supabase's free tier meters egress bytes, not request count, so splitting
this into two round trips is a straight win: phase one selects only the
metadata columns needed to reconcile, phase two fetches `data` for the ids
that reconciliation decided to pull. A no-change sync now transfers a few
KB and issues no second request at all.
Phase two chunks its id list at 100 because `in.(...)` filters travel in
the query string. Rows that disappear between the two phases are skipped
rather than treated as empty.
Projects stay single-phase: their payload *is* their metadata, so there
would be nothing left to defer.
---
services/sync.ts | 87 ++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 73 insertions(+), 14 deletions(-)
diff --git a/services/sync.ts b/services/sync.ts
index 1acb4fc..43607a7 100644
--- a/services/sync.ts
+++ b/services/sync.ts
@@ -194,6 +194,48 @@ interface RemoteGraphRow {
deleted: boolean;
}
+/**
+ * A graph row without its `data` blob. Reconciliation only needs the
+ * timestamps and flags to decide what to do; `data` is fetched afterwards for
+ * the handful of rows actually being pulled.
+ */
+type RemoteGraphMeta = Omit;
+
+/** Columns that decide reconciliation. Everything here is a few bytes per row. */
+const GRAPH_META_COLUMNS = 'id, project_id, title, created_at_ms, last_modified, deleted';
+
+/**
+ * PostgREST puts filters in the query string, so a single `in.(…)` list of
+ * UUIDs has to stay under the server's URL length limit. 100 ids is ~3.7 KB,
+ * comfortably inside it, and a first sync pulling thousands of graphs just
+ * issues a few requests.
+ */
+const PULL_CHUNK_SIZE = 100;
+
+/**
+ * Fetch the `data` blobs for exactly the graphs being pulled.
+ *
+ * The alternative, selecting `data` for the whole library in the reconciliation
+ * query, downloaded every diagram the user owns on every sync, including the
+ * overwhelmingly common case where nothing changed at all. Supabase's free tier
+ * is metered on egress rather than request count, so trading one large response
+ * for a small one plus an occasional second round trip is a large saving and
+ * costs nothing measurable.
+ */
+async function fetchGraphData(ids: string[]): Promise