Skip to content

Supporter plan, cloud backend, and SEO pages (1.1.0) - #1

Merged
Sukarth merged 29 commits into
mainfrom
release/1.1.0
Jul 29, 2026
Merged

Supporter plan, cloud backend, and SEO pages (1.1.0)#1
Sukarth merged 29 commits into
mainfrom
release/1.1.0

Conversation

@Sukarth

@Sukarth Sukarth commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Adds an optional account and Supporter tier on top of the existing editor, plus build-time SEO landing pages. The core editor stays free and fully offline: with none of the new environment variables set, every cloud feature hides itself and the app behaves exactly as it does today.

Squashed to a single commit. main was still 1.0.0 and had none of this; 1.1.0 was never tagged or released, so everything here ships as 1.1.0.

What this adds

Accounts and cloud (Supabase)

  • Email + password and Google sign-in, with password reset.
  • Cloud sync for graphs, projects and custom templates, with version history.
  • View-only share links on unguessable slugs, resolved anonymously through a get_share() RPC so the shares table itself is never readable by anon (no bulk enumeration).
  • Full schema in supabase/schema.sql with row-level security on every table. Entitlement is a single rule, pro_until > now(), enforced identically in SQL (is_pro()) and TypeScript (services/entitlement.ts).

Hosted AI

  • /api/generate runs generation server-side for supporters, so they need no API key.
  • Three interchangeable backends, first configured wins: Vertex AI express key, Vertex AI with a project (ADC locally, service account on Vercel), or a Google AI Studio key. See docs/BACKEND_SETUP.md section 2.
  • Metered per user per month via atomic SQL, default 150. A failed upstream call is refunded; a response that comes back but fails to parse is not, so it cannot be farmed.
  • Free users keep unlimited generation with their own key.

Billing (Polar)

  • Checkout, customer portal, and a signature-verified webhook that is the only writer of billing columns (column-level grants stop users editing their own entitlement).
  • Renewal is cushioned by a 1-day margin and never moved backward by a delayed or out-of-order event, while cancellation still ends access immediately.
  • Account deletion cancels any live subscription before deleting, so a deleted account can never keep being billed.

SEO

  • 12 static diagram landing pages plus a hub and sitemap.xml, generated at build time into dist/. Self-contained HTML, no external CSS or JS.
  • public/sitemap.xml is removed because it is now generated.

Ops

  • db-keepalive.yml pings the database every ~5 days so a free-tier Supabase project never pauses after 7 days idle.
  • update-supporters.yml refreshes the supporters list weekly.

Licensing

  • AGPL-3.0, with the section 13 source offer linked from Settings.
  • The project name, logo and branding are reserved separately from the code licence, so forks run under their own branding.
  • Privacy Policy and Terms pages, governed by Finnish law and preserving EU/EEA consumer rights.

Testing

Verified against a live Supabase project, Polar sandbox, and Vertex AI using a real service-account key.

Path Result
Hosted AI via Vertex (Pro) 200, valid diagrams, usage metered each time
Hosted AI as non-Pro 402 not_pro
Checkout (monthly + yearly) 200, real Polar checkout URL
Real sandbox payment, end to end webhook delivered, entitlement written with real Polar IDs
Webhook signature invalid rejected (403), valid accepted
Cloud sync round-trip survives reload; rows + version snapshots written
Version history listed, previewed and restored
Share links created, resolved anonymously payload-only, revoked
Custom templates saved, synced, deleted behind a confirmation
Account deletion cancelled the live subscription, cascade-deleted all rows
DB triggers version cap and purge-on-delete both proven functionally
Both GitHub workflows keepalive HTTP 200; supporters script idempotent
Production build succeeds, emits 12 pages + hub + sitemap (18 URLs)

Client UI checked page by page: landing, pricing, compare, privacy, terms, editor (drawing, templates, export), dashboard, settings and the auth modal.

Not covered here: the production Vercel runtime and production Polar, Google OAuth, password reset, quota exhaustion at 150, and subscription lifecycle past activation (cancel, past-due, renewal). These need a smoke test on the live deployment after deploy.

Deploying

Set the environment variables in docs/BACKEND_SETUP.md section 4, point a production Polar webhook at /api/webhooks/polar, add the SUPABASE_URL and SUPABASE_SECRET_KEY repository secrets so the scheduled workflows run, and apply supabase/schema.sql.


Summary by Sourcery

Add optional cloud-backed Supporter plan with accounts, hosted AI, billing, and SEO landing pages while keeping the core editor fully free and offline-compatible.

New Features:

  • Introduce Supabase-backed accounts with optional cloud sync for graphs, projects, version history, share links, and custom templates.
  • Add hosted AI provider that runs Gemini generation server-side for Supporters with monthly per-user metering.
  • Implement Polar-based billing with checkout, customer portal, and webhook-driven entitlement updates for the Supporter plan.
  • Add public shareable view-only links for individual graphs and projects resolved via unguessable slugs.
  • Provide a cloud version-history viewer and restore flow for synced diagrams.
  • Add pricing and comparison pages, plus static SEO diagram landing pages and sitemap generation at build time.
  • Add privacy policy and terms of service pages and surface AGPL-3.0 source-code offer in-app.

Enhancements:

  • Refine AI provider abstraction to support Gemini, OpenRouter, and the new hosted provider with unified gating and messaging.
  • Improve editor UX with provider-aware AI warnings, preserved user-defined graph titles, and extended component templates that include text labels.
  • Implement robust local/cloud sync engine with tombstones, ID remapping, and conflict-handling to avoid data bleed across accounts and devices.
  • Add dev-only Vite middleware to run serverless API routes locally without Vercel and relax host checks for HTTPS tunnel testing.
  • Centralize API key obfuscation and diagram schema/prompt logic for reuse between client and server.
  • Rework landing and settings pages to highlight the free-forever guarantee, Supporter benefits, and open-source licensing details.

Build:

  • Extend Vite config with a dev-time shim to serve Vercel API routes from the Vite dev server and allow tunneled hosts.
  • Update production build to generate static SEO diagram pages and sitemap.xml after Vite output.
  • Add environment variable scaffolding in .env.example for Supabase, Polar, and hosted AI backends.

CI:

  • Add GitHub workflow to keep the Supabase database warm on free tier and prevent automatic pausing.
  • Add GitHub workflow to periodically refresh the public supporters list in the README from the database.

Documentation:

  • Expand README with Supporter plan details, free-forever guarantee, backend architecture overview, and updated licensing information.
  • Add backend setup guide documenting how to configure Supabase, hosted AI backends, Polar billing, and supporters automation.

Tests:

  • Documented manual test matrix for hosted AI, billing, cloud sync, sharing, workflows, and builds; no automated test changes included in this diff.

Chores:

  • Bump app version to 1.1.0 and add a 1.1.0 changelog entry describing new cloud, billing, SEO, and licensing changes.
  • Relicense the project from MIT to AGPL-3.0 and clarify that name and branding remain reserved.

Summary by CodeRabbit

Summary

  • New Features
    • Added Supporter plan with hosted AI (metered usage/limits), cloud sync with version history, revocable view-only share links, and synced custom templates.
    • Introduced pricing/compare plus privacy/terms pages, plus prerendered SEO diagram content.
    • Added hosted AI checkout/usage UI and cloud/account settings, including hosted billing portal and hosted-AI generation flow.
  • Bug Fixes
    • Improved cloud sync/restore robustness, tombstone-based deletion handling, and safer shared-link viewing/routing.
  • Documentation
    • Updated environment examples, backend setup guide, and refreshed README/CHANGELOG for AGPL licensing and Supporter features.

Adds an optional account and Supporter tier on top of the existing editor,
plus build-time SEO landing pages. The core editor stays free and fully
offline: with none of the new environment variables set, every cloud feature
hides itself and the app behaves exactly as before.

Accounts and cloud (Supabase)
- Email + password and Google sign-in, with password reset.
- Cloud sync for graphs, projects and custom templates, with version history.
- View-only share links on unguessable slugs, resolved anonymously through a
  get_share() RPC so the shares table is never readable by anon.
- Full schema in supabase/schema.sql with RLS on every table. Entitlement is
  one rule, pro_until > now(), enforced identically in SQL and TypeScript.

Hosted AI
- /api/generate runs generation server-side for supporters.
- Three interchangeable backends, first configured wins: Vertex AI express
  key, Vertex AI with a project (ADC locally, service account on Vercel), or
  a Google AI Studio key.
- Metered per user per month via atomic SQL, default 150. Upstream failures
  are refunded; a response that arrives but fails to parse is not, so it
  cannot be farmed.
- Free users keep unlimited generation with their own key.

Billing (Polar)
- Checkout, customer portal, and a signature-verified webhook that is the
  only writer of billing columns.
- Renewal is cushioned by a 1-day margin and never moved backward by a
  delayed or out-of-order event; cancellation still ends access immediately.
- Account deletion cancels any live subscription before deleting.

SEO
- 12 static diagram pages plus a hub and sitemap.xml, generated at build time
  into dist/. public/sitemap.xml is removed because it is now generated.

Ops
- db-keepalive.yml pings the database every ~5 days so a free-tier Supabase
  project never pauses; update-supporters.yml refreshes the supporters list.

Licensing
- AGPL-3.0, with the section 13 source offer linked from Settings. The
  project name, logo and branding are reserved separately from the code
  licence, so forks run under their own branding.
- Privacy Policy and Terms pages, governed by Finnish law and preserving
  EU/EEA consumer rights.
Copilot AI review requested due to automatic review settings July 27, 2026 18:02
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ib-econgraph-ai Ready Ready Preview, Comment Jul 29, 2026 3:18am

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @Sukarth, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

The release adds optional Supporter features backed by Supabase, Polar, and hosted AI, including authentication, cloud synchronization, version history, sharing, templates, billing, account deletion, and new public pages. It also adds SEO page generation, deployment configuration, documentation, and scheduled maintenance workflows.

Supporter cloud platform

Layer / File(s) Summary
Platform foundations
supabase/schema.sql, api/*, services/auth.tsx, services/billing.ts
Adds database tables and RLS, authentication, billing endpoints, hosted usage metering, webhook entitlement updates, account deletion, and API/deployment configuration.
Hosted AI
api/generate.ts, services/diagramPrompt.ts, services/hostedAi.ts, services/ai.ts
Adds hosted provider routing, shared diagram prompts and schemas, authenticated generation, monthly quotas, timeout handling, and usage reporting.
Cloud synchronization
services/localStore.ts, services/sync.ts, services/useCloudSync.ts, App.tsx
Adds scoped local persistence, tombstones, ID remapping, last-write-wins reconciliation, version snapshots, share refreshes, and debounced synchronization.
Sharing and templates
services/shares.ts, services/customTemplates.ts, components/ShareModal.tsx, components/CloudHistoryModal.tsx, components/ComponentLibrary.tsx
Adds share payloads and public links, version restoration, and Supporter custom-template management.
Application integration
App.tsx, components/*, services/auth.tsx, index.tsx
Wires authentication, cloud state, new routes, provider gating, account controls, pricing, legal pages, shared viewing, and editor actions.
SEO and release wiring
scripts/*, README.md, docs/BACKEND_SETUP.md, .github/workflows/*, vite.config.ts, vercel.json
Adds static diagram pages, route shells, sitemap generation, backend setup documentation, release metadata, scheduled maintenance, and API-aware development/deployment routing.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main scope: Supporter plan, cloud backend, and SEO pages for v1.1.0.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release/1.1.0

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds optional cloud-backed Supporter features (accounts, sync, hosted AI, billing, sharing, custom templates) plus static SEO pages, while preserving the existing fully local free editor behavior when backend env vars are absent.

Sequence diagram for hosted AI generation via /api/generate

sequenceDiagram
  participant App
  participant HostedClient as hostedAi.generateDiagramDataHosted
  participant Api as /api/generate
  participant Supa as Supabase
  participant Gemini as GoogleGenAI

  App->>HostedClient: generateDiagramDataHosted(prompt, history)
  HostedClient->>Api: POST /api/generate
  Api->>Supa: getUserFromRequest(token)
  Api->>Supa: getProfile(user.id)
  Api->>Supa: increment_ai_usage(p_user, p_month, p_limit)
  alt quota exceeded
    Supa-->>Api: newCount = -1
    Api-->>HostedClient: 429 quota_exceeded
    HostedClient-->>App: Error (quota exceeded)
  else within quota
    Api->>Gemini: models.generateContent(model, contents, config)
    alt upstream failure
      Gemini-->>Api: [error]
      Api->>Supa: refund_ai_usage(p_user, p_month)
      Api-->>HostedClient: 502 error
      HostedClient-->>App: Error (hosted AI failed)
    else success
      Gemini-->>Api: response.text (diagram JSON)
      Api-->>HostedClient: 200 { diagram, usage }
      HostedClient-->>App: DiagramData
    end
  end
Loading

Sequence diagram for Polar subscription webhook updating entitlement

sequenceDiagram
  participant Polar as Polar
  participant Webhook as /api/webhooks/polar
  participant SupaAdmin as SupabaseAdmin

  Polar->>Webhook: POST subscription.* (signed)
  Webhook->>Webhook: validateEvent(rawBody, headers, POLAR_WEBHOOK_SECRET)
  alt subscription event
    Webhook->>SupaAdmin: getProfile(userId)
    alt status in {active,trialing,past_due}
      Webhook->>SupaAdmin: update profiles
      Note right of SupaAdmin: pro_status = status
      Note right of SupaAdmin: pro_until = max(current, period_end + margin)
    else terminal status (canceled,revoked,unpaid)
      Webhook->>SupaAdmin: update profiles
      Note right of SupaAdmin: pro_status = status
      Note right of SupaAdmin: pro_until = now()
    end
  else other event
    Webhook-->>Polar: 202 received
  end
  Webhook-->>Polar: 202 received
Loading

File-Level Changes

Change Details Files
Wire the SPA into new routes, auth, cloud sync, hosted AI gating, sharing, and SEO-aware behavior without breaking the existing offline editor flow.
  • Extend App routing to support pricing/compare/legal/shared views and keep document title/canonical URL in sync with the SPA path.
  • Integrate Supabase auth context and a new cloud sync hook so Supporters can sync graphs/projects with version history while guarding against cross-account data leakage and first-pull races.
  • Add hosted-AI provider mode that routes generation through a server endpoint and gate AI usage based on auth/entitlement versus BYOK API key presence, with unified UI messaging.
  • Introduce share and cloud-history modals wired into toolbar actions, and update component props to surface sync state and pricing navigation.
  • Update landing and settings pages to expose Pricing/Compare nav, free-forever guarantee, hosted-vs-BYOK provider selection, and AGPL/source/licensing/footer links.
App.tsx
index.html
index.tsx
services/ai.ts
services/aiProvider.ts
services/gemini.ts
services/openrouter.ts
components/LandingPage.tsx
components/SettingsPage.tsx
Introduce local-first cloud sync, tombstoning, ID normalization, and version-history management against a Supabase backend.
  • Implement a sync engine that reconciles local graphs/projects with Supabase using lastModified timestamps, tombstone tracking, and UUID remapping for legacy IDs.
  • Define Supabase schema and RLS policies for profiles, projects, graphs, graph_versions, shares, templates, and ai_usage, including entitlement checks via is_pro() and hard caps on stored versions.
  • Add client helpers for Supabase admin/auth usage, entitlement evaluation, RLS error detection, and exposure of only the publishable key to the browser.
services/sync.ts
services/useCloudSync.ts
supabase/schema.sql
services/supabaseClient.ts
services/entitlement.ts
services/cloudErrors.ts
Add Supporter-only custom templates, shareable view-only links, and cloud version-history UI on top of the existing component library and editor.
  • Extend ComponentLibrary with a "My Templates" section that loads, saves, and deletes user-scoped templates via Supabase, backed by a per-user local cache and confirmation flows for destructive operations.
  • Add share-link creation/revocation for graphs and projects using an unguessable slug plus payload-only RPC, and surface the share URL with copy/revoke controls.
  • Expose per-graph version history fetched from graph_versions, and allow restoring a snapshot into the current diagram with undo-compatible behavior.
components/ComponentLibrary.tsx
components/ShareModal.tsx
components/CloudHistoryModal.tsx
services/customTemplates.ts
services/shares.ts
Implement Supabase-backed auth, billing (Polar), hosted AI metering, and account deletion endpoints plus a dev server shim for API routes.
  • Add an AuthProvider around the app that manages Supabase sessions, profiles, entitlement, password reset flows, and profile updates, exposing a hook used throughout the UI.
  • Create serverless API routes for hosted AI generation with per-user/month limits, usage querying, and a multi-backend Gemini/Vertex configuration strategy.
  • Integrate Polar for checkout, customer portal, and subscription webhooks, including logic to avoid double-charging, cushion renewals, and write entitlement fields atomically on the profile row.
  • Provide an account-deletion API that cancels any active subscription first, then deletes the auth user cascaded across all tables.
  • Add a Vite dev plugin that proxies /api/* requests to the local Node handlers via ssrLoadModule so npm run dev exercises real API code, and document full backend setup/operations.
services/auth.tsx
api/generate.ts
api/usage.ts
api/checkout.ts
api/portal.ts
api/webhooks/polar.ts
api/delete-account.ts
api/_lib/supabaseAdmin.ts
api/_lib/polar.ts
services/hostedAi.ts
services/billing.ts
vite.config.ts
docs/BACKEND_SETUP.md
Add SEO-focused static diagram landing pages plus a diagrams hub and sitemap generation at build time, and update marketing/licensing copy to reflect the Supporter plan and AGPL.
  • Define rich per-diagram-type content specs and a build script that renders static /diagrams/* HTML pages with inline SVG, plus a sitemap.xml covering app and SEO pages.
  • Introduce dedicated Pricing, Compare, Privacy, and Terms React pages wired into router paths, emphasizing the free-forever guarantee and Supporter feature set.
  • Relicense the project from MIT to AGPL-3.0 with explicit branding reservation, update the README with backend/stack sections and a supporters list, and add a changelog entry for 1.1.0.
  • Add GitHub workflows to keep the Supabase DB warm and to periodically regenerate the supporters section in the README from the profiles table.
  • Wire Vercel routing so SEO pages and SPA routes coexist cleanly and ensure the production build runs the SEO generator after vite build.
scripts/seo-content.mjs
scripts/generate-seo-pages.mjs
README.md
CHANGELOG.md
components/PricingPage.tsx
components/ComparePage.tsx
components/LegalPages.tsx
package.json
.github/workflows/db-keepalive.yml
.github/workflows/update-supporters.yml
scripts/update-supporters.mjs
vercel.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add Supporter plan: Supabase accounts/sync, hosted AI, Polar billing, and SEO pages

✨ Enhancement 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add optional Supabase accounts with Supporter entitlement, cloud sync, share links, and version
 history.
• Add hosted AI endpoint with monthly quotas, plus Polar checkout/portal/webhook billing.
• Generate static SEO landing pages + sitemap at build time, and add ops GitHub workflows.
Diagram

graph TD
  SPA["React SPA"] -->|"JWT"| API["Vercel API"] -->|"admin DB/RPC"| DB[("Supabase DB")]
  SPA -->|"auth"| AUTH{{"Supabase Auth"}}
  SPA -->|"sync/share"| DB
  API -->|"billing"| POLAR{{"Polar"}}
  API -->|"generate"| GENAI{{"Google GenAI"}}
  BUILD["SEO generator"] -->|"writes"| DIST["dist SEO pages"]

  subgraph Legend
    direction LR
    _svc["Component"] ~~~ _db[("Database")] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Adopt a routing library (e.g., React Router) for SPA pages
  • ➕ More robust route matching and nested layouts
  • ➕ Easier future expansion (guards, params, 404s) without manual URL parsing
  • ➖ Adds dependency weight and integration work for a small set of routes
  • ➖ May complicate static SEO page precedence vs SPA rewrites
2. Move serverless endpoints to Supabase Edge Functions
  • ➕ Keeps backend logic co-located with database/auth
  • ➕ Potentially simpler secrets management if already all-in on Supabase
  • ➖ Changes hosting/runtime assumptions vs Vercel
  • ➖ Webhook raw-body handling and local dev may be more involved
3. Use server-side conflict resolution instead of client LWW + tombstones
  • ➕ Potentially fewer sync edge-cases across devices
  • ➕ Centralizes merge policy
  • ➖ Higher complexity and operational burden
  • ➖ Harder to keep the product local-first and debuggable offline

Recommendation: The PR’s approach is a pragmatic fit for a local-first app: optional Supabase wiring (feature-hidden when unconfigured), client-controlled LWW reconciliation with tombstones, and minimal backend surface area on Vercel for hosted AI + billing. Consider a routing library only if page/route complexity keeps growing; otherwise the current lightweight parsePath approach is acceptable.

Files changed (54) +10730 / -1094

Enhancement (32) +4641 / -69
App.tsxAdd routing, cloud gating, share links, and sync integration +326/-46

Add routing, cloud gating, share links, and sync integration

• Extends the SPA with new routes (pricing/compare/legal/shared view) and dynamic canonical/title updates. Integrates Supabase auth + Supporter gating, debounced cloud sync, share modal, and cloud history restore while keeping offline-first behavior when unconfigured.

App.tsx

polar.tsAdd Polar SDK client + canonical app URL resolution +43/-0

Add Polar SDK client + canonical app URL resolution

• Implements a cached Polar client and a robust origin/canonical URL resolver for redirects across Vercel, local dev, and tunnels. Centralizes billing backend configuration validation.

api/_lib/polar.ts

supabaseAdmin.tsAdd server-side Supabase admin helpers and entitlement utilities +73/-0

Add server-side Supabase admin helpers and entitlement utilities

• Creates a cached admin client using the Supabase secret key and helpers for extracting the user from Bearer JWTs. Adds shared utilities for hosted AI monthly limits and month bucketing.

api/_lib/supabaseAdmin.ts

checkout.tsCreate Polar checkout sessions for Supporter plan +69/-0

Create Polar checkout sessions for Supporter plan

• Adds a serverless endpoint that validates the signed-in user, prevents double-subscription, and returns a Polar checkout URL. Supports monthly/yearly product IDs and redirects back to Settings on success.

api/checkout.ts

delete-account.tsDelete account with billing-safe subscription cancellation +82/-0

Delete account with billing-safe subscription cancellation

• Implements account deletion that first revokes an active Polar subscription (abort on genuine cancel failure) and then deletes the Supabase auth user to cascade-delete all associated rows.

api/delete-account.ts

generate.tsAdd hosted AI generation endpoint with quotas and refunds +205/-0

Add hosted AI generation endpoint with quotas and refunds

• Introduces a Supporter-only /api/generate endpoint that selects a configured Google backend (Vertex express / Vertex project / AI Studio), meters usage atomically, and returns validated diagram JSON. Handles auth/config failure paths explicitly to keep BYOK deployments unaffected.

api/generate.ts

portal.tsAdd Polar customer-portal session endpoint +38/-0

Add Polar customer-portal session endpoint

• Creates a serverless endpoint returning a customer portal URL for subscription management and invoices. Requires a signed-in user and handles missing billing state gracefully.

api/portal.ts

usage.tsExpose hosted AI usage for current month +46/-0

Expose hosted AI usage for current month

• Adds a GET endpoint returning monthly hosted AI usage counts and current entitlement status for the signed-in user. Used by Settings UI to display quotas for supporters.

api/usage.ts

polar.tsAdd signature-verified Polar webhook to update entitlement +165/-0

Add signature-verified Polar webhook to update entitlement

• Implements a raw-body webhook handler verifying Polar signatures and updating profile billing fields via service role only. Adds ordering/staleness protections and a renewal boundary safety margin without granting post-cancel access.

api/webhooks/polar.ts

AccountSection.tsxAdd Settings ‘Account & Cloud’ card (sign-in, plan, sync, usage, delete) +487/-0

Add Settings ‘Account & Cloud’ card (sign-in, plan, sync, usage, delete)

• Adds a comprehensive account UI for sign-in/out, Supporter status, hosted AI usage meter, sync controls, supporter-name opt-in, password updates, billing portal access, and account deletion flows.

components/AccountSection.tsx

AuthModal.tsxAdd authentication modal (email/password + Google + reset) +261/-0

Add authentication modal (email/password + Google + reset)

• Provides a unified modal for sign-in, sign-up (with email confirmation), Google OAuth, and password reset flows. Designed to minimize email volume while enabling required account creation for Supporter features.

components/AuthModal.tsx

CloudHistoryModal.tsxAdd cloud version history viewer and restore action +102/-0

Add cloud version history viewer and restore action

• Introduces a modal listing stored graph versions from the cloud and restoring a selected diagram snapshot. Gated behind signed-in Supporter entitlement.

components/CloudHistoryModal.tsx

ComparePage.tsxAdd comparison marketing page +268/-0

Add comparison marketing page

• Adds a standalone compare page explaining positioning vs alternatives and linking into the editor/pricing flows. Used as an SEO/landing route in the SPA.

components/ComparePage.tsx

ComponentLibrary.tsxIntegrate custom template library hooks +196/-4

Integrate custom template library hooks

• Extends the component/template library UI to support user-defined templates (cloud-synced when available) while preserving the built-in template experience.

components/ComponentLibrary.tsx

LandingPage.tsxUpdate landing page content for new routes/features +72/-9

Update landing page content for new routes/features

• Refreshes the landing page to align with the Supporter plan messaging and new navigation routes (pricing/compare/legal).

components/LandingPage.tsx

PricingPage.tsxAdd Supporter plan pricing page and checkout entrypoint +329/-0

Add Supporter plan pricing page and checkout entrypoint

• Implements a pricing page that explains free vs Supporter features and triggers Polar checkout when eligible. Handles unconfigured deployments and sign-in requirements via modal flow.

components/PricingPage.tsx

SettingsPage.tsxAdd AccountSection and hosted AI provider option +107/-5

Add AccountSection and hosted AI provider option

• Adds the Account & Cloud section to Settings and introduces a new AI provider option for hosted generation when Supabase is configured. Improves backup import copy to reflect cross-device synced data implications.

components/SettingsPage.tsx

ShareModal.tsxAdd view-only share link creation/copy/revoke UI +182/-0

Add view-only share link creation/copy/revoke UI

• Provides a modal for creating and managing unguessable share links for the current graph, including copy/revoke actions. Clearly communicates gating and upgrade paths when not entitled.

components/ShareModal.tsx

SharedViewPage.tsxAdd anonymous shared link viewer (/s/:slug) +179/-0

Add anonymous shared link viewer (/s/:slug)

• Adds a public, read-only viewer for shared links that loads payloads anonymously via an RPC-backed lookup. Supports both single-graph and multi-graph project shares with a simple navigator.

components/SharedViewPage.tsx

index.tsxWire app-level providers for auth/cloud features +5/-2

Wire app-level providers for auth/cloud features

• Updates the React entrypoint to wrap the app in the new auth/provider context needed for Supabase-backed features and gating.

index.tsx

ai.tsAdd hosted provider option to AI generation +8/-0

Add hosted provider option to AI generation

• Extends AI dispatch to support a new 'hosted' provider that routes generation through /api/generate. Keeps BYOK behavior unchanged for existing providers.

services/ai.ts

aiProvider.tsAdd persistent 'hosted' AI provider selection +8/-3

Add persistent 'hosted' AI provider selection

• Adds a new provider enum value and display name for hosted AI. Ensures invalid persisted values fall back safely to the default provider.

services/aiProvider.ts

auth.tsxAdd Supabase auth context, profile loading, and entitlement state +237/-0

Add Supabase auth context, profile loading, and entitlement state

• Introduces an AuthProvider with session restoration, profile fetching, and helpers for sign-up/sign-in/reset/update flows. Exposes a single isPro signal derived from profile pro_until for consistent gating.

services/auth.tsx

billing.tsAdd client helpers for checkout, portal, and account deletion +56/-0

Add client helpers for checkout, portal, and account deletion

• Provides authenticated fetch helpers to call billing-related serverless endpoints using the current Supabase JWT. Centralizes error handling and user-friendly messaging for billing flows.

services/billing.ts

cloudErrors.tsNormalize cloud/RLS error detection +9/-0

Normalize cloud/RLS error detection

• Adds a small utility to detect RLS-denied errors so the UI can show entitlement-specific messaging instead of raw database errors.

services/cloudErrors.ts

customTemplates.tsAdd cloud-synced custom templates + graph version history fetch +149/-0

Add cloud-synced custom templates + graph version history fetch

• Implements template CRUD backed by Supabase with a per-user local cache to stay offline-friendly and avoid cross-account bleed. Also adds a query helper for graph_versions used by the history modal.

services/customTemplates.ts

entitlement.tsCentralize Supporter entitlement rule in TypeScript +13/-0

Centralize Supporter entitlement rule in TypeScript

• Defines the single entitlement check (pro_until in the future) shared by client and server helpers. Explicitly documents the need to keep it aligned with the SQL is_pro() function.

services/entitlement.ts

hostedAi.tsAdd client wrapper for hosted AI generation and usage meter +60/-0

Add client wrapper for hosted AI generation and usage meter

• Implements client-side calls to /api/generate and /api/usage using Supabase JWTs, with response shape validation and friendly error messages. Used by the editor AI flow and AccountSection usage UI.

services/hostedAi.ts

shares.tsAdd share payload helpers and anonymous fetch via RPC +150/-0

Add share payload helpers and anonymous fetch via RPC

• Implements share slug generation, share CRUD for graphs/projects, and anonymous share resolution via get_share() RPC to prevent table enumeration. Ensures shared payloads exclude chat history.

services/shares.ts

supabaseClient.tsAdd optional Supabase client wiring and JWT accessor +28/-0

Add optional Supabase client wiring and JWT accessor

• Creates the Supabase browser client only when publishable configuration is present, keeping cloud features hidden by default. Adds a helper to fetch the current access token for authenticated API calls.

services/supabaseClient.ts

sync.tsImplement local-first cloud sync with tombstones and version snapshots +539/-0

Implement local-first cloud sync with tombstones and version snapshots

• Adds a Supabase-backed sync engine that reconciles local graphs/projects with last-write-wins semantics, tombstones for deletions, and optional ID remapping for legacy backups. Writes graph version snapshots with pruning and keeps share payloads refreshed.

services/sync.ts

useCloudSync.tsAdd debounced sync hook with focus/online refresh and rerun protection +149/-0

Add debounced sync hook with focus/online refresh and rerun protection

• Introduces a React hook that schedules sync runs, prevents concurrent execution, and re-queues sync when local state changes mid-run. Handles offline detection and performs opportunistic refresh on tab focus and reconnect.

services/useCloudSync.ts

Refactor (4) +150 / -154
diagramPrompt.tsShare Gemini system prompt/schema between client and server +125/-0

Share Gemini system prompt/schema between client and server

• Extracts the diagram system instruction, response schema, and history-context builder into a shared module usable in both browser and serverless contexts. Keeps hosted and BYOK outputs consistent.

services/diagramPrompt.ts

gemini.tsRefactor Gemini provider to use shared prompt/schema and key obfuscation +8/-141

Refactor Gemini provider to use shared prompt/schema and key obfuscation

• Replaces duplicated prompt/schema with imports from services/diagramPrompt and extracts localStorage key obfuscation into a shared utility. Keeps BYOK Gemini behavior intact while enabling hosted parity.

services/gemini.ts

keyObfuscation.tsExtract API key obfuscation helper +14/-0

Extract API key obfuscation helper

• Adds a small shared utility for obfuscating/deobfuscating locally stored API keys to prevent casual exposure in DevTools.

services/keyObfuscation.ts

openrouter.tsAlign OpenRouter provider behavior with new AI plumbing +3/-13

Align OpenRouter provider behavior with new AI plumbing

• Updates OpenRouter provider integration to fit the expanded provider selection model and shared prompt conventions introduced by hosted AI support.

services/openrouter.ts

Documentation (5) +1302 / -27
CHANGELOG.mdAdd 1.1.0 release notes +61/-1

Add 1.1.0 release notes

• Documents the Supporter plan release scope including accounts/cloud sync, hosted AI, billing, SEO pages, and licensing/legal additions.

CHANGELOG.md

README.mdUpdate docs for Supporter plan, hosting, and supporters list +108/-26

Update docs for Supporter plan, hosting, and supporters list

• Expands README with new feature descriptions, supporter recognition section, and updated setup guidance consistent with optional cloud configuration.

README.md

LegalPages.tsxAdd Privacy Policy and Terms pages +254/-0

Add Privacy Policy and Terms pages

• Introduces self-contained Privacy and Terms pages with consistent layout, internal navigation, and last-updated metadata. These routes are linked from the app and canonicalized in App routing.

components/LegalPages.tsx

BACKEND_SETUP.mdDocument Supabase/Polar/hosted-AI setup and security model +261/-0

Document Supabase/Polar/hosted-AI setup and security model

• Adds an end-to-end backend setup guide covering Supabase schema/RLS, auth provider configuration, SMTP considerations, hosted AI backend choices, and Polar billing configuration. Emphasizes optionality and zero-config defaults.

docs/BACKEND_SETUP.md

seo-content.mjsAdd declarative SEO page content and diagram specs +618/-0

Add declarative SEO page content and diagram specs

• Provides the data model and content backing for generated diagram landing pages, including keywords, copy, and declarative SVG diagram specifications.

scripts/seo-content.mjs

Other (13) +4637 / -844
.env.exampleDocument optional cloud/billing/AI environment variables +62/-0

Document optional cloud/billing/AI environment variables

• Expands the example environment file with Supabase client/server keys, Polar billing configuration, and hosted-AI backend options. Preserves a zero-config default where cloud features remain disabled.

.env.example

db-keepalive.ymlAdd scheduled Supabase keepalive ping +47/-0

Add scheduled Supabase keepalive ping

• Introduces a workflow that periodically issues a lightweight database read to prevent free-tier Supabase projects from pausing. Uses repository secrets for Supabase URL and secret key.

.github/workflows/db-keepalive.yml

update-supporters.ymlAdd weekly Supporters list refresh workflow +46/-0

Add weekly Supporters list refresh workflow

• Adds a scheduled workflow to regenerate the README Supporters section from the database and commit changes automatically. Supports manual runs and prevents concurrent executions.

.github/workflows/update-supporters.yml

.gitignoreIgnore additional generated/local artifacts +3/-0

Ignore additional generated/local artifacts

• Updates ignore rules to avoid committing new build/dev artifacts introduced by the backend/ops tooling.

.gitignore

index.htmlAdjust head metadata for expanded routes/SEO +3/-3

Adjust head metadata for expanded routes/SEO

• Updates static HTML metadata to better support new canonical/title behavior and SEO flows introduced by the new routes and build-time pages.

index.html

package-lock.jsonUpdate lockfile for new backend/billing/AI dependencies +3361/-822

Update lockfile for new backend/billing/AI dependencies

• Locks dependency graph changes required for Supabase, Polar SDK, Google GenAI, and related tooling introduced in this release.

package-lock.json

package.jsonAdd dependencies and scripts for cloud backend and SEO generation +9/-3

Add dependencies and scripts for cloud backend and SEO generation

• Adds/updates packages needed for Supabase auth/db, Polar billing, Google GenAI, and build-time SEO generation. Aligns scripts for release/ops workflows.

package.json

generate-seo-pages.mjsGenerate static diagram landing pages and sitemap.xml into dist/ +418/-0

Generate static diagram landing pages and sitemap.xml into dist/

• Implements a build-time generator that produces self-contained HTML landing pages (inline CSS/SVG) and sitemap.xml. Designed to be served as clean URLs ahead of the SPA rewrite.

scripts/generate-seo-pages.mjs

update-supporters.mjsAdd script to regenerate README supporters list from Supabase +61/-0

Add script to regenerate README supporters list from Supabase

• Adds a Node script that queries entitled supporters and updates a bounded README block, escaping user-provided names for Markdown safety. Used by the scheduled GitHub workflow.

scripts/update-supporters.mjs

schema.sqlAdd full Supabase schema with RLS, entitlement, shares RPC, and metering +488/-0

Add full Supabase schema with RLS, entitlement, shares RPC, and metering

• Introduces tables for profiles, graphs/projects, versions, templates, shares, and AI usage, with row-level security on all data. Adds is_pro() entitlement, get_share() RPC for anonymous share resolution, and atomic usage functions plus supporting triggers/grants.

supabase/schema.sql

vercel.jsonServe clean URLs and protect /api from SPA rewrites +3/-1

Serve clean URLs and protect /api from SPA rewrites

• Enables cleanUrls and adjusts rewrites to avoid rewriting /api routes to index.html. Supports serving generated static SEO pages ahead of the SPA fallback.

vercel.json

vite-env.d.tsAdd/expand Vite env type declarations +10/-0

Add/expand Vite env type declarations

• Extends TypeScript environment typings for new VITE_* variables used to optionally configure Supabase on the client.

vite-env.d.ts

vite.config.tsAdd dev API shim and tunnel-friendly dev server configuration +126/-15

Add dev API shim and tunnel-friendly dev server configuration

• Adds a Vite dev-server plugin to run Vercel-style serverless handlers from /api during local development, including JSON body parsing and raw-body webhook exemptions. Also configures allowedHosts for common dev tunnels and exposes server env vars to the dev API runtime only.

vite.config.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an optional Supporter tier with cloud-backed features (Supabase auth/sync/sharing, Polar billing, and Vercel serverless endpoints) while preserving the existing “offline-first, free editor” behavior when no backend env vars are configured. It also adds new SEO/marketing/legal pages plus ops automation (keepalive + supporters refresh) and bumps the app to 1.1.0.

Changes:

  • Add Supabase-backed accounts, entitlement gating, cloud sync + version history, share links, and custom templates.
  • Add hosted AI and billing flows via Vercel serverless functions (Polar checkout/portal/webhook + usage metering).
  • Add pricing/compare/legal pages and supporting docs/workflows; update build and deployment routing.

Reviewed changes

Copilot reviewed 52 out of 55 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
vite.config.ts Adds a dev-only Vite middleware to run api/* handlers locally; updates server host settings.
vite-env.d.ts Declares Vite env typings for Supabase client configuration.
vercel.json Updates routing/rewrites behavior for SPA vs /api/*.
supabase/schema.sql Introduces full Supabase schema with RLS, entitlement helper functions, share RPC, and usage metering functions.
services/useCloudSync.ts Adds a debounced, self-healing cloud sync React hook.
services/sync.ts Implements local-first sync engine, tombstones, version snapshotting, and share refresh logic.
services/supabaseClient.ts Adds optional Supabase client initialization (null when unconfigured).
services/shares.ts Implements share slug generation, share upserts, revoke, and public payload fetch via RPC.
services/openrouter.ts Refactors key storage to shared obfuscation helper.
services/keyObfuscation.ts Adds shared key obfuscation helpers for BYOK providers.
services/hostedAi.ts Adds client wrapper for hosted AI generation + usage fetch.
services/gemini.ts Refactors prompt/schema to shared module and key obfuscation helper.
services/entitlement.ts Adds shared entitlement predicate (pro_until > now).
services/diagramPrompt.ts Centralizes Gemini system prompt and JSON schema for client+server reuse.
services/customTemplates.ts Adds cloud-synced custom templates and graph version history fetch.
services/cloudErrors.ts Adds shared RLS-denial detection for nicer UX messaging.
services/billing.ts Adds client billing helpers for checkout/portal/account deletion.
services/auth.tsx Adds Supabase auth/profile context, recovery mode, and profile update helpers.
services/aiProvider.ts Adds “hosted” as an AI provider option and display name logic.
services/ai.ts Routes diagram generation to hosted AI when selected; updates “has key” logic.
scripts/update-supporters.mjs Adds script to refresh README supporters list from Supabase.
README.md Updates docs for Supporter plan, free-forever guarantee, architecture, and new pages.
public/sitemap.xml Removes static sitemap (now generated at build time).
package.json Bumps version to 1.1.0; adds build step for SEO generation and new dependencies.
index.tsx Wraps app in AuthProvider.
index.html Updates SEO titles for OG/Twitter meta tags.
docs/BACKEND_SETUP.md Adds comprehensive self-hosting guide for Supabase/Polar/hosted AI.
components/ShareModal.tsx Adds UI flow for creating/copying/revoking share links (Supporter-gated).
components/SharedViewPage.tsx Adds public read-only shared view route UI for graphs/projects.
components/SettingsPage.tsx Adds Account & Cloud section integration and hosted AI provider UX.
components/PricingPage.tsx Adds pricing page with free-vs-supporter messaging and checkout flow.
components/LegalPages.tsx Adds Privacy Policy + Terms pages.
components/LandingPage.tsx Updates landing page nav/CTAs and licensing/support messaging.
components/ComponentLibrary.tsx Adds Supporter custom template library UI (save/delete/sync).
components/ComparePage.tsx Adds a comparison page vs other tools.
components/CloudHistoryModal.tsx Adds UI to browse and restore cloud version history (Supporter-gated).
components/AuthModal.tsx Adds auth modal (signin/signup/forgot + Google OAuth).
CHANGELOG.md Adds 1.1.0 release notes.
api/webhooks/polar.ts Adds signature-verified Polar webhook to maintain entitlement state.
api/usage.ts Adds hosted AI usage endpoint.
api/portal.ts Adds billing portal endpoint.
api/generate.ts Adds hosted AI generation endpoint with entitlement + monthly metering.
api/delete-account.ts Adds account deletion endpoint that cancels billing then deletes user.
api/checkout.ts Adds checkout endpoint with safeguards against double-subscription.
api/_lib/supabaseAdmin.ts Adds server-side Supabase admin helpers and entitlement/month helpers.
api/_lib/polar.ts Adds Polar client helper and robust app URL resolution for redirects.
.gitignore Ignores .vercel and .env* (except .env.example).
.github/workflows/update-supporters.yml Adds scheduled workflow to refresh README supporters list.
.github/workflows/db-keepalive.yml Adds scheduled workflow to keep free-tier Supabase DB from pausing.
.env.example Adds documented env var template for optional backend features.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread vite.config.ts Outdated
Comment on lines +124 to +127
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
Comment thread vercel.json
Comment on lines +6 to 7
"source": "/((?!api/).*)",
"destination": "/index.html"
Comment thread api/usage.ts
Comment on lines +39 to +40
const used = usageResult.data?.count ?? 0;
return res.status(200).json({
Comment thread package.json Outdated
"version": "1.1.0",
"description": "Free and open-source AI-powered economics diagram editor built for IB students and educators.",
"type": "module",
"license": "MIT",
Comment thread components/LandingPage.tsx Outdated
Comment thread CHANGELOG.md
@qodo-code-review

qodo-code-review Bot commented Jul 27, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Potential hosted key exposure ✓ Resolved 🐞 Bug ⛨ Security
Description
vite.config.ts inlines env.GEMINI_API_KEY into process.env.GEMINI_API_KEY via define, so any
client-bundled code (including dependencies) that references that symbol can cause the hosted secret
to ship to browsers. This directly conflicts with the hosted-AI handler’s guarantee that the Gemini
API key “never leaves the server.”
Code

vite.config.ts[R124-127]

+        define: {
+            'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
+            'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
+        },
Evidence
Vite’s define performs compile-time substitution; here it maps process.env.GEMINI_API_KEY to the
loaded secret. The hosted AI endpoint also reads the same environment variable and explicitly claims
the key stays server-side, making the build-time inlining a security footgun.

vite.config.ts[104-127]
api/generate.ts[40-78]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`vite.config.ts` defines `process.env.GEMINI_API_KEY` (and `process.env.API_KEY`) with the value from `loadEnv(..., prefix='')`, which includes non-`VITE_` secrets. If any code included in the browser bundle references `process.env.GEMINI_API_KEY` (directly or inside a dependency), Vite will inline the secret into shipped JS.

### Issue Context
The PR introduces server-side hosted AI (`/api/generate`) that reads `process.env.GEMINI_API_KEY` and states the key never leaves the server. The current Vite `define` configuration increases the risk of leaking that same secret to the client.

### Fix Focus Areas
- Remove the `define` entries for `process.env.GEMINI_API_KEY` and `process.env.API_KEY` entirely, or replace them with safe placeholders (e.g., `undefined`) if some legacy code expects them.
- If a public, client-side default key is ever needed, require a `VITE_`-prefixed variable explicitly meant for client exposure.

- vite.config.ts[124-127]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Webhook ignores select errors ✓ Resolved 🐞 Bug ☼ Reliability
Description
applySubscriptionState() reads the current profile row but ignores the Supabase error; if that
SELECT fails, it proceeds as if pro_until was unset (currentEnd = 0) and can write an incorrect
pro_until/subscription state. This can cause entitlement regressions during transient DB/PostgREST
failures instead of forcing a retry.
Code

api/webhooks/polar.ts[R56-65]

+    const { data: current } = await admin
+        .from('profiles')
+        .select('polar_subscription_id, pro_until')
+        .eq('id', userId)
+        .maybeSingle();
+    const onFile = current?.polar_subscription_id;
+    const differentSub = !!onFile && onFile !== sub.id;
+    const DAY_MS = 24 * 60 * 60 * 1000;
+    const currentEnd = current?.pro_until ? Date.parse(current.pro_until) : 0;
+
Evidence
The code destructures only data from the profile read and then computes currentEnd from that
value; the subsequent update always runs unless it returns early due to subscription comparisons, so
a failed read removes the guardrails that depend on current state.

api/webhooks/polar.ts[53-65]
api/webhooks/polar.ts[100-116]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The Polar webhook handler uses the current stored subscription/pro_until to prevent out-of-order events from clobbering newer entitlement, but it does not check the error returned by `.maybeSingle()`. When that read fails, the code silently falls back to `currentEnd = 0` and continues to update the row.

### Issue Context
This webhook is the only writer of billing/entitlement fields. A transient read failure should not be treated the same as “no current subscription”; it should abort and return non-2xx so Polar retries.

### Fix Focus Areas
- Capture and check `{ data, error }` from the profile SELECT.
- If `error` is non-null, throw (or return a 500) so Polar retries and entitlement doesn’t get overwritten without the safety checks.
- Consider logging the error with enough context (userId/sub.id) for debugging.

- api/webhooks/polar.ts[56-65]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Share revocation can miss links ✓ Resolved 🐞 Bug ≡ Correctness
Description
Share creation looks up an existing share ID and then upserts by primary key id, but the
shares table has no uniqueness constraint on (kind, graph_id) / (kind, project_id), so
concurrent creations can insert multiple share rows for the same graph/project. Revoking a single
id then leaves other active share links accessible.
Code

services/shares.ts[R87-99]

+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(),
+    });
Evidence
The client only ever revokes by a single returned id, but the database schema doesn’t prevent
multiple share rows per graph/project, and the creation path can generate a fresh slug if it doesn’t
see an existing row, enabling duplicates under concurrent calls.

services/shares.ts[63-102]
services/shares.ts[125-129]
supabase/schema.sql[326-335]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The app behavior/UI implies a single share link per graph/project (“Revoke link”), but the current implementation can create duplicates under concurrency:
- `getShareIdForGraph()`/`getShareIdForProject()` selects only one row.
- `upsert()` conflicts only on `shares.id`.
- The schema does not enforce uniqueness for graph/project foreign keys.
This can leave “revoked” content still shared via a different duplicate row.

### Issue Context
Because `get_share()` is intentionally anon-executable, stale duplicate share rows are a real privacy/access-control risk: older links can remain valid indefinitely.

### Fix Focus Areas
- Add partial unique indexes:
 - unique(kind, graph_id) where kind='graph' and graph_id is not null
 - unique(kind, project_id) where kind='project' and project_id is not null
- Change share creation to a single `upsert(..., { onConflict: 'kind,graph_id' })` / `onConflict: 'kind,project_id'` (or equivalent), removing the separate “select then insert” race.
- Optionally harden revocation by deleting by (kind, graph_id/project_id) when revoking from a graph/project context.

- services/shares.ts[63-129]
- supabase/schema.sql[326-335]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread vite.config.ts Outdated
Comment thread api/webhooks/polar.ts Outdated
Comment thread services/shares.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
components/SettingsPage.tsx (1)

394-404: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Persisted hosted provider leaves the page in a dead state when cloud isn't configured.

If hosted was stored previously (or the deployment drops the Supabase env vars), provider stays 'hosted' while its <option> isn't rendered: the select shows blank, both the key and model sections are hidden, and AI generation has no working provider. Consider falling back to a BYOK provider on mount when !cloudConfigured.

🐛 Sketch of the fallback
     useEffect(() => {
         const p = getAIProvider();
-        setProviderState(p);
-        loadProviderState(p);
-    }, []);
+        const effective = p === 'hosted' && !cloudConfigured ? 'gemini' : p;
+        if (effective !== p) setAIProvider(effective);
+        setProviderState(effective);
+        loadProviderState(effective);
+    }, [cloudConfigured]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/SettingsPage.tsx` around lines 394 - 404, Update the provider
initialization in SettingsPage so a persisted "hosted" value is replaced with a
supported BYOK provider when cloudConfigured is false. Ensure this fallback runs
on mount or when configuration is loaded, while preserving the existing provider
selection when hosted is available and keeping the select, key fields, and model
sections synchronized.
App.tsx (1)

690-721: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Move fetchCloudIds() before the state updates.

setGraphs/setProjects at Lines 702-703 immediately schedule a debounced cloud sync, while the cloud-only tombstones are only recorded after the awaited network round trip. If the sync fires first, exactly the resurrection this block guards against can still happen. Fetching the ids up front closes the window.

🛠️ Suggested reordering
     const importedGraphIds = new Set(data.graphs.map(g => g.id));
     const importedProjectIds = new Set(data.projects.map(p => p.id));
+    // Best-effort: null when offline. Done before mutating state so the
+    // debounced sync can't start with an incomplete tombstone set.
+    const cloud = await fetchCloudIds();
+    if (cloud) {
+      recordTombstones('graphs', cloud.graphIds.filter(id => !importedGraphIds.has(id)));
+      recordTombstones('projects', cloud.projectIds.filter(id => !importedProjectIds.has(id)));
+    }
     recordTombstones('graphs', graphs.filter(g => !importedGraphIds.has(g.id)).map(g => g.id));

and drop the trailing fetchCloudIds() block at Lines 712-720.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@App.tsx` around lines 690 - 721, Update handleImportData so fetchCloudIds()
runs before setGraphs and setProjects, then record tombstones for cloud-only
graph and project IDs using the imported ID sets. Remove the trailing
fetchCloudIds block after the state updates, preserving the existing best-effort
null handling.
🟡 Minor comments (15)
scripts/seo-content.mjs-454-458 (1)

454-458: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the stale MIT license claim.

Line 457 says the project is MIT licensed, but the README and changelog now declare AGPL-3.0. This publishes contradictory licensing information on the PPC page.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/seo-content.mjs` around lines 454 - 458, Update the licensing
statement in the faq array of seo-content.mjs to reference AGPL-3.0 instead of
MIT, keeping the rest of the classroom-use answer unchanged.
docs/BACKEND_SETUP.md-163-165 (1)

163-165: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reconcile the billing grace-period documentation.

Line 164 specifies a 3-day grace period, while CHANGELOG.md Lines 54-56 says 1 day. Document the implemented value consistently before operators configure billing expectations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/BACKEND_SETUP.md` around lines 163 - 165, Reconcile the grace-period
value described near the webhook entitlement documentation with the implemented
billing behavior and the corresponding CHANGELOG entry. Update the conflicting
documentation so the stated grace period is consistent across both references,
preserving the existing entitlement and renewal semantics.
docs/BACKEND_SETUP.md-101-105 (1)

101-105: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify languages for the environment-variable code blocks.

These fences trigger the reported MD040 warnings. Mark them as dotenv to keep documentation lint-clean.

Proposed fix
-```
+```dotenv
 VERTEX_API_KEY=...
 ...
</details>


Also applies to: 121-127, 132-136

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/BACKEND_SETUP.md around lines 101 - 105, Update the
environment-variable code fences in BACKEND_SETUP.md, including the blocks
around VERTEX_API_KEY and the additional referenced blocks, to specify the
dotenv language. Preserve their existing contents and formatting while ensuring
every affected fence is marked for dotenv syntax.


</details>

<!-- cr-comment:v1:38c8396769f02cec77cca658 -->

_Source: Linters/SAST tools_

</blockquote></details>
<details>
<summary>api/_lib/polar.ts-32-40 (1)</summary><blockquote>

`32-40`: _🔒 Security & Privacy_ | _🟡 Minor_ | _⚡ Quick win_

**Do not use arbitrary request origins for payment redirects.**

Line 32 accepts a caller-controlled `Origin`; without `APP_URL`, an authenticated checkout can be configured to return to an attacker-controlled domain. Require `APP_URL` outside explicit local development, or validate against a fixed allowlist.

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @api/_lib/polar.ts around lines 32 - 40, Update the origin-selection logic
around the origin header and fallback URL so payment redirects never use an
arbitrary caller-controlled Origin. Require the configured APP_URL (or an
equivalent fixed allowlist) for non-local environments, while preserving direct
localhost/loopback handling for explicit local development; ensure authenticated
checkout cannot fall back to an untrusted request origin.


</details>

<!-- cr-comment:v1:396ced5f66a22355f5bf7de7 -->

</blockquote></details>
<details>
<summary>components/LegalPages.tsx-37-39 (1)</summary><blockquote>

`37-39`: _🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_

**Increase contrast for muted metadata and footer text.**

`text-gray-400` on white is insufficient for normal-size text under WCAG AA’s 4.5:1 minimum. Use `text-gray-500` or darker for the update date and footer’s resting state. ([w3.org](https://www.w3.org/TR/WCAG22/?utm_source=openai))

<details>
<summary>Proposed fix</summary>

```diff
-            <p className="text-sm text-gray-400 mb-10">Last updated: {LAST_UPDATED}</p>
+            <p className="text-sm text-gray-500 mb-10">Last updated: {LAST_UPDATED}</p>
...
-            <footer className="mt-16 pt-8 border-t border-slate-100 text-sm text-gray-400 flex flex-wrap gap-x-6 gap-y-2">
+            <footer className="mt-16 pt-8 border-t border-slate-100 text-sm text-gray-500 flex flex-wrap gap-x-6 gap-y-2">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/LegalPages.tsx` around lines 37 - 39, Update the Last updated
paragraph and footer in the LegalPages component to use text-gray-500 or a
darker text color instead of text-gray-400, preserving the existing layout and
other styling.

Source: MCP tools

components/ComponentLibrary.tsx-247-271 (1)

247-271: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Custom template rows aren't keyboard reachable.

The row is a div with onClick only, so keyboard users can't add a saved template (the nested delete button is focusable, the row itself isn't). Add role="button", tabIndex={0}, and an Enter/Space handler — or render the row content as a button.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ComponentLibrary.tsx` around lines 247 - 271, Update the custom
template rows rendered in filteredCustom.map to be keyboard accessible: add
button semantics, make each row focusable, and handle Enter and Space by
invoking addCustomTemplate(t), while preserving the nested delete button’s
stopPropagation behavior.
components/AccountSection.tsx-82-106 (1)

82-106: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Keep the checkout poll attempt counter stable across refreshProfile recreations.

refreshProfile is only memoized to fetchProfile; if fetchProfile depends on values that can change during checkout polling, this effect can tear down and restart, resetting the local attempts counter and preventing the “taking longer” fallback. Move the attempt count into a ref or otherwise keep the counter stable across effect iterations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/AccountSection.tsx` around lines 82 - 106, The checkout poll
attempt counter in the useEffect must persist when refreshProfile changes and
the effect restarts. Move attempts to a useRef (or equivalent stable state),
reset it when a new checkout begins, increment the stable value during polling,
and use it for the delayed fallback while preserving the existing cleanup
behavior.
components/AuthModal.tsx-165-196 (1)

165-196: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Email/password inputs lack accessible names.

Only placeholders are provided (no <label> or aria-label), so screen-reader users get no reliable field name. Add aria-label (or visually-hidden labels) to both inputs, and to the reset-form email field at Line 127.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/AuthModal.tsx` around lines 165 - 196, Add accessible names to
both email and password inputs in the AuthModal form, and to the reset-form
email input near the reset flow. Use clear aria-labels or existing visually
hidden labels, while preserving the current input behavior and autocomplete
settings.
components/ComponentLibrary.tsx-213-213 (1)

213-213: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Enter key bypasses the disabled-state guard.

handleSaveTemplate has no internal saving/empty-name check, so pressing Enter repeatedly can fire concurrent inserts (creating duplicate templates) or submit a blank name. Guard inside the handler.

🐛 Proposed fix
     const handleSaveTemplate = async () => {
+        if (saving || !saveName.trim()) return;
         if (!user) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ComponentLibrary.tsx` at line 213, Update handleSaveTemplate in
ComponentLibrary so it immediately returns when a save is already in progress or
the template name is empty, ensuring both button clicks and the Enter-key
onKeyDown path share the same guard and cannot create duplicate or blank
templates.
App.tsx-822-828 (1)

822-828: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Title becomes permanently AI-immutable after the first generation.

userNamed is true for any title other than EMPTY_DIAGRAM.title, and the first generation writes the AI title back into graph.title (Line 844). Every later prompt therefore keeps the first diagram's title even though the user never renamed anything. If the intent is "respect explicit renames", track that with a flag set by renameGraph rather than inferring it from the current title.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@App.tsx` around lines 822 - 828, Replace the title-based userNamed inference
in the generation flow with an explicit flag recorded by renameGraph. Set that
flag only when the user renames the graph, and use it when deciding whether to
preserve activeGraph.title, so AI-generated titles remain updateable until an
explicit rename occurs.
components/SharedViewPage.tsx-168-174 (1)

168-174: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Stray space before the comma in the footer.

{' '} followed by a line beginning with , renders as "IB EconGraph AI , the free, open-source…" on a public, link-shared page.

✏️ Fix
                 <button onClick={onGoHome} className="text-blue-600 hover:underline font-medium">
                     IB EconGraph AI
-                </button>{' '}
-               , the free, open-source economics diagram editor for IB students.
+                </button>
+                , the free, open-source economics diagram editor for IB students.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/SharedViewPage.tsx` around lines 168 - 174, Remove the whitespace
expression following the “IB EconGraph AI” button in the footer of
SharedViewPage, so the comma renders immediately after the linked text while
preserving the intended spacing after the comma.
services/diagramPrompt.ts-13-15 (1)

13-15: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Prompt sentence is ungrammatical. "If an equilibrium point E is at (50, 50), ensuring the Supply Curve..." has no main clause; use "ensure".

✏️ Proposed fix
-     - 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).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/diagramPrompt.ts` around lines 13 - 15, Correct the “Shared
Coordinates (CRITICAL)” prompt text in diagramPrompt.ts by changing the
ungrammatical “If an equilibrium point E is at (50, 50), ensuring…” sentence to
use “ensure” as the main instruction, while preserving its coordinate-matching
requirements.
api/generate.ts-156-183 (1)

156-183: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add a short timeout around the model call. The upstream API has no bound here, so a hanging response is only covered by the platform timeout and bypasses the existing refund path; abort after a short deadline so failed generations do not consume a counted credit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/generate.ts` around lines 156 - 183, Update the model call in the
generate handler’s try/catch to use a short, explicit timeout with an abort
signal, ensuring a hanging ai.models.generateContent request rejects and reaches
the existing refund path. Preserve the current error logging, refund_ai_usage
call, and 502 response behavior for timeout failures.
services/keyObfuscation.ts-7-9 (1)

7-9: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

btoa throws on non-Latin1 input. A pasted key containing any character above U+00FF (stray unicode from a copy/paste) raises InvalidCharacterError. The read side is wrapped in try/catch by callers, but the save side isn't (services/openrouter.ts Line 12, services/gemini.ts Line 14), so the settings save flow throws instead of reporting a bad key.

🛡️ Proposed hardening
 export function obfuscateKey(key: string): string {
-    return OBFUSCATION_PREFIX + btoa(key);
+    // Encode to UTF-8 first so non-Latin1 characters don't throw.
+    return OBFUSCATION_PREFIX + btoa(String.fromCharCode(...new TextEncoder().encode(key)));
 }
 
 export function deobfuscateKey(stored: string): string {
     if (!stored.startsWith(OBFUSCATION_PREFIX)) return stored;
-    return atob(stored.slice(OBFUSCATION_PREFIX.length));
+    const bin = atob(stored.slice(OBFUSCATION_PREFIX.length));
+    return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
 }

Note this changes the encoding of newly stored keys; existing ASCII-only values decode identically, so no migration is needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/keyObfuscation.ts` around lines 7 - 9, Update obfuscateKey to encode
the key as UTF-8 before passing it to btoa, preventing InvalidCharacterError for
Unicode input. Update the corresponding decode path to reverse the UTF-8
encoding, while preserving identical decoding for existing ASCII-only stored
keys.
api/delete-account.ts-10-14 (1)

10-14: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add ON DELETE CASCADE from graphs to graph_versions.

graph_versions.graph_id is non-null but has no FK/cascade, and graphs.user_id only cascades to profiles. Deleting a user removes graphs, but leaves existing graph_versions rows and their created_at index entries behind, so the deletion path does not fully match the docstring’s cascade guarantee.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/delete-account.ts` around lines 10 - 14, Update the database schema
migration for graph_versions.graph_id to add a foreign key referencing graphs
with ON DELETE CASCADE. Preserve the non-null constraint and ensure existing
graph_versions rows and their related indexes are removed when a graph is
deleted, matching the cascade behavior described in the delete-account
documentation.
🧹 Nitpick comments (15)
services/useCloudSync.ts (1)

87-93: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Track the re-run timeout so cleanup can cancel it.

The 500 ms re-run timer isn't stored in a ref, so the unmount cleanup at Line 140 can't clear it; a sync can still fire after unmount/sign-out. Reusing timerRef (or a second ref) makes it cancellable.

♻️ Proposed change
         } finally {
             runningRef.current = false;
             if (rerunRef.current) {
                 rerunRef.current = false;
-                window.setTimeout(() => { void runSync(); }, 500);
+                if (timerRef.current) window.clearTimeout(timerRef.current);
+                timerRef.current = window.setTimeout(() => {
+                    timerRef.current = null;
+                    void runSync();
+                }, 500);
             }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/useCloudSync.ts` around lines 87 - 93, Store the 500 ms timeout
created in the finally block of runSync in the existing timerRef (or a dedicated
timeout ref), and have the unmount/sign-out cleanup clear that stored timer
before resetting it. Keep the rerunRef behavior unchanged while ensuring runSync
cannot be triggered by this delayed callback after cleanup.
components/ComponentLibrary.tsx (1)

70-79: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Guard against out-of-order/stale template fetches.

fetchCustomTemplates resolves asynchronously; if user/isOpen/isPro changes before it lands, a stale response can overwrite the newer list (including the signed-out []). A cancellation flag in the effect avoids it.

♻️ Proposed change
     useEffect(() => {
         if (!user) {
             setCustomTemplates([]);
             return;
         }
         setCustomTemplates(listCachedTemplates(user.id));
-        if (isOpen && isPro) {
-            fetchCustomTemplates(user.id).then(setCustomTemplates);
-        }
+        if (!isOpen || !isPro) return;
+        let cancelled = false;
+        fetchCustomTemplates(user.id).then((t) => { if (!cancelled) setCustomTemplates(t); });
+        return () => { cancelled = true; };
     }, [isOpen, user, isPro]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ComponentLibrary.tsx` around lines 70 - 79, Add a per-effect
cancellation flag in the useEffect that loads custom templates, set it during
cleanup, and only apply fetchCustomTemplates results while the effect is still
active. Preserve the immediate signed-out reset and cached-template behavior,
ensuring stale responses cannot overwrite newer state.
components/ShareModal.tsx (1)

33-43: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Both modals key their load effect on the graph object instead of graph.id. activeGraph in App.tsx is a useMemo derived from graphs, so its identity changes on every autosave; while either modal is open, ordinary diagram editing re-runs these effects and re-issues Supabase queries.

  • components/ShareModal.tsx#L33-L43: replace graph with graph?.id in the effect body and dependency array.
  • components/CloudHistoryModal.tsx#L33-L41: replace graph with graph?.id in the effect body and dependency array, and reset versions when the id changes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ShareModal.tsx` around lines 33 - 43, Key both modal load effects
on the graph ID rather than the changing graph object. In
components/ShareModal.tsx lines 33-43, use graph?.id in the effect logic and
dependency array; in components/CloudHistoryModal.tsx lines 33-41, do the same
and reset versions when the graph ID changes.
components/ComparePage.tsx (1)

147-191: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Comparison table needs row headers and a keyboard-scrollable container.

Row labels are plain <td>, so assistive tech can't associate a cell with its row; and the overflow-x-auto wrapper around a min-w-[760px] table can't be scrolled by keyboard alone because it isn't focusable.

♿ Proposed change
-                    <div className="overflow-x-auto rounded-2xl border border-slate-200 shadow-sm">
+                    <div
+                        className="overflow-x-auto rounded-2xl border border-slate-200 shadow-sm"
+                        tabIndex={0}
+                        role="region"
+                        aria-label="Feature comparison"
+                    >
@@
-                                    <th className="p-4 font-semibold text-gray-500 w-[22%]"></th>
+                                    <th scope="col" className="p-4 font-semibold text-gray-500 w-[22%]">
+                                        <span className="sr-only">Feature</span>
+                                    </th>
@@
-                                        <td className="p-4 font-medium text-gray-700">{row.label}</td>
+                                        <th scope="row" className="p-4 font-medium text-gray-700 text-left">{row.label}</th>

(the remaining <th>s in <thead> should also get scope="col".)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ComparePage.tsx` around lines 147 - 191, Update the comparison
table in the ROWS mapping to render each row label as a row header with
scope="row", and add scope="col" to every header cell in the table header. Make
the overflow-x-auto wrapper keyboard-focusable, preserving its horizontal
scrolling behavior for keyboard users.
components/LandingPage.tsx (1)

750-761: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Route Privacy/Terms through the SPA callback contract.

The Vercel rewrite covers /privacy and /terms, but these hard links still reload the page while the adjacent footer buttons use onOpenPricing/onOpenCompare. Pass onOpenPrivacy/onOpenTerms props to LandingPage for consistent SPA navigation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/LandingPage.tsx` around lines 750 - 761, Update the LandingPage
footer Privacy and Terms links to use the SPA callback contract instead of
hard-coded href navigation: accept onOpenPrivacy and onOpenTerms props in
LandingPage, then invoke the corresponding callbacks from those links while
preserving their existing styling and labels. Ensure the parent passes both
callbacks into LandingPage.
services/billing.ts (1)

3-24: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No timeout on network requests.

callBillingEndpoint and deleteAccount have no request timeout; a hung response leaves the caller's loading UI stuck indefinitely. See consolidated comment with services/hostedAi.ts for a shared fix.

Also applies to: 40-56

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/billing.ts` around lines 3 - 24, Update callBillingEndpoint and
deleteAccount to enforce a finite timeout on their network requests, using the
shared timeout approach referenced for services/hostedAi.ts. Ensure timed-out
requests abort and flow through the existing error handling so callers do not
remain loading indefinitely.
services/hostedAi.ts (2)

21-33: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Missing request timeout on hosted AI calls.

Neither generateDiagramDataHosted nor fetchHostedUsage set a timeout/AbortController. See the consolidated comment for a shared fix across this file and services/billing.ts.

Also applies to: 52-54

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/hostedAi.ts` around lines 21 - 33, Add a request timeout using an
AbortController to both generateDiagramDataHosted and fetchHostedUsage, passing
its signal to each fetch call and aborting after the established timeout
interval. Ensure timers are cleaned up when requests complete, while preserving
the existing connection-error handling.

1-1: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add request timeouts to first-party API fetch() calls. None of the client-side wrappers around /api/generate, /api/usage, /api/checkout, /api/portal, or /api/delete-account set a timeout, so a hung or very slow server response leaves the caller's loading UI (spinner, "Generating…", checkout/portal buttons) stuck indefinitely with no user recourse short of a page reload.

  • services/hostedAi.ts#L21-33: wrap the /api/generate fetch with an AbortController timeout (AI generation is the longest-running, highest-visibility call).
  • services/hostedAi.ts#L52-54: wrap the /api/usage fetch with the same timeout helper.
  • services/billing.ts#L3-24: wrap callBillingEndpoint's fetch with the same timeout helper.
  • services/billing.ts#L40-56: wrap deleteAccount's fetch with the same timeout helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/hostedAi.ts` at line 1, Add a shared AbortController-based timeout
helper and use it for the fetch calls in hostedAi generation and usage flows,
plus billing’s callBillingEndpoint and deleteAccount. Ensure each request aborts
after the configured timeout while preserving existing request handling and
error propagation.
services/sync.ts (1)

264-267: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Every debounced sync round-trips full diagram payloads for all graphs/projects, even when nothing changed remotely.

The select at Lines 265-266 pulls the full data JSON blob for every graph/project the user owns on every syncCloud() call, not just the ones that actually changed. Given this runs on a "debounced sync path" (per the comment at Line 454) potentially on every edit, this means repeatedly transferring the full diagram content for a user's entire library even when only last_modified needs checking for most rows.

Consider a two-phase approach: first select only id, last_modified, deleted to diff against local state, then fetch full data only for the rows that actually need to be pulled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/sync.ts` around lines 264 - 267, Update the syncCloud data-fetch
flow around the graphRes and projectRes queries to use two phases: initially
select only id, last_modified, and deleted for remote diffing, then fetch the
full metadata and data payload only for rows determined to require pulling.
Preserve existing handling for unchanged, deleted, and locally modified records
while avoiding full data transfers on every debounced sync round.
api/generate.ts (1)

40-72: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider hoisting the client out of the request path. resolveAiClient() constructs a new GoogleGenAI (and re-parses GOOGLE_SERVICE_ACCOUNT_JSON) on every invocation; env is static per instance, so a module-level memo saves per-request work and lets ADC token caching survive across warm invocations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/generate.ts` around lines 40 - 72, Hoist the resolved AI client out of
the request path by memoizing the result of resolveAiClient() at module scope.
Ensure GOOGLE_SERVICE_ACCOUNT_JSON is parsed and GoogleGenAI is constructed once
per instance, while preserving the existing environment-priority selection and
null behavior; reuse the cached client wherever resolveAiClient() is currently
invoked.
package.json (1)

39-39: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

dev:api depends on an undeclared vercel CLI. Contributors without a global install get a "command not found". Either add vercel to devDependencies or document the prerequisite in the setup docs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 39, Add the Vercel CLI package to package.json's
devDependencies so the existing dev:api script can run without requiring a
global installation. Use the project's existing dependency versioning
conventions and avoid changing the script itself.
supabase/schema.sql (1)

458-464: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Insert branch ignores p_limit. With p_limit <= 0 (e.g. quota disabled via config) the first generation each month still succeeds. Add where p_limit > 0 semantics if a zero limit should ever be meaningful.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/schema.sql` around lines 458 - 464, The ai_usage upsert does not
enforce p_limit on the initial insert. Update the insert/upsert flow around the
ai_usage conflict handler so p_limit <= 0 prevents inserting a first monthly
usage record and follows the existing quota-exceeded behavior, while preserving
the current increment guard for existing records.
.github/workflows/db-keepalive.yml (2)

37-40: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add curl timeouts. Without --max-time, a hung connection stalls the job until the 6h default timeout.

♻️ Proposed change
           code=$(curl -s -o /dev/null -w '%{http_code}' \
+            --connect-timeout 10 --max-time 30 \
             "$SUPABASE_URL/rest/v1/profiles?select=id&limit=1" \
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/db-keepalive.yml around lines 37 - 40, Update the curl
invocation in the keepalive workflow to include a finite maximum request
duration using curl’s timeout option, ensuring a hung Supabase request cannot
stall the job for the workflow’s full timeout period while preserving the
existing URL, headers, and HTTP-status capture.

12-16: 🩺 Stability & Availability | 🔵 Trivial

Keepalive depends on scheduled workflows staying enabled. GitHub disables schedule triggers in repositories with no activity for 60 days, and scheduled runs can be delayed or dropped under load — either silently defeats the 7-day pause guard. Consider a redundant external ping (e.g. an uptime/cron service hitting the same PostgREST URL) or alerting on missed runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/db-keepalive.yml around lines 12 - 16, Augment the
scheduled keepalive defined by the workflow’s on.schedule configuration with a
redundant external ping or equivalent missed-run alert, targeting the same
PostgREST keepalive endpoint. Ensure the fallback preserves the requirement that
the database is contacted within every seven-day window even if GitHub disables
or delays scheduled workflow runs.
api/portal.ts (1)

26-37: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Config errors are indistinguishable from "no billing account" here.

If getPolar() throws because POLAR_ACCESS_TOKEN isn't set, this returns the same 404 "No billing account found... wait a few seconds and try again" as a genuine missing-customer case — misleading during an actual misconfiguration/outage, and inconsistent with the 503 "not configured on this deployment" pattern used elsewhere in this PR (checkout.ts, delete-account.ts, usage.ts).

♻️ Proposed fix
     let user;
     try {
         user = await getUserFromRequest(req);
     } catch (err) {
         console.error('portal: auth backend error', err);
         return res.status(503).json({ error: 'Account service is not configured on this deployment.' });
     }
     if (!user) {
         return res.status(401).json({ error: 'Please sign in first.' });
     }
+
+    let polar;
+    try {
+        polar = getPolar();
+    } catch (err) {
+        console.error('portal: billing backend 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,
         });
         return res.status(200).json({ url: session.customerPortalUrl });
     } catch (err) {
         console.error('portal: failed to create customer session', err);
         return res.status(404).json({
             error: 'No billing account found. If you just subscribed, wait a few seconds and try again.',
         });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/portal.ts` around lines 26 - 37, Update the error handling around
getPolar and polar.customerSessions.create so configuration failures such as a
missing POLAR_ACCESS_TOKEN return the established 503 “not configured on this
deployment” response, while genuine missing-customer errors retain the existing
404 response. Follow the existing handling pattern used by checkout.ts,
delete-account.ts, or usage.ts and keep the portal success response unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/update-supporters.yml:
- Around line 25-46: Update the checkout step to set persist-credentials to
false, then configure authentication immediately before the README commit/push
commands in “Commit if the README changed” using the workflow token. Keep npm ci
running without persisted Git credentials and preserve the existing conditional
commit behavior.

In `@api/generate.ts`:
- Around line 124-133: Update the profile lookup and entitlement flow around
getProfile and isProfilePro so a failed profile request is handled separately
from a successfully loaded non-Pro profile. Preserve the existing 402 not_pro
response only when profile data confirms the user lacks entitlement; for lookup
errors, return an appropriate server-error response instead of treating the user
as unauthorized.
- Around line 139-154: Update the quota check following the increment_ai_usage
RPC in the generate handler to fail closed when newCount is not a number. Reject
unexpected null or string results with the existing server-error response path,
while preserving the 429 quota-exceeded response for numeric values below zero.

In `@api/webhooks/polar.ts`:
- Line 22: Move the entitled Polar status set into services/entitlement.ts as
the shared ENTITLED_POLAR_STATUSES constant, then import and use it in
api/webhooks/polar.ts, api/checkout.ts, and api/delete-account.ts instead of
defining local status sets, preserving the existing active, trialing, and
past_due values.
- Around line 56-110: The read-compute-write flow in applySubscriptionState is
vulnerable to concurrent same-subscription webhook updates overwriting each
other. Make the profile state transition atomic by moving the current-state
read, entitlement calculation, and update into a transactional/Postgres function
that locks the user row with SELECT ... FOR UPDATE, or condition the existing
update on the previously-read pro_until and polar_subscription_id and retry when
no row is updated; preserve the existing stale-subscription and non-decreasing
proUntil guards.

In `@App.tsx`:
- Around line 254-273: Update the cross-account guard in the useEffect keyed by
user?.id and hasInitialized so switching accounts no longer clears or overwrites
the previous user's local-only graphs and projects. Namespace persisted local
data by owner, including an anonymous bucket, and load/save the active user's
namespace when the owner changes; preserve each account's data for later return
instead of replacing it with empty arrays. Do not rely on the current global
STORAGE_KEYS.owner flow as the sole isolation mechanism.

In `@components/LegalPages.tsx`:
- Around line 131-137: Update the child-account handling associated with the
“Children & students” section to require verifiable parental consent before
collecting data from users under the applicable age, or block account creation
for those minors through an age-gating flow. Ensure signup paths for
email/password and Google accounts enforce this outcome, and revise the policy
text to accurately describe the implemented behavior after legal review.
- Around line 79-81: Update the hosted AI prompts disclosure in LegalPages to
match the configured backend behavior: either enforce Vertex AI
zero-data-retention and disable logging/tracking before every
ai.models.generateContent call across VERTEX_API_KEY/GOOGLE_CLOUD_PROJECT paths,
or replace the blanket no-training statement with provider-specific prompt
handling disclosures.
- Around line 205-210: Update the hosted AI legal text in the “Hosted AI & fair
use” section of LegalPages to replace the “unlimited generations” BYOK promise
with wording that clarifies BYOK is not metered by this app but remains subject
to the provider’s limits, usage rules, and costs.

In `@services/shares.ts`:
- Around line 63-85: Update getShareIdForGraph and getShareIdForProject to
inspect and propagate Supabase lookup errors instead of returning null on
failures; preserve null only when the query succeeds with no matching share.
Adjust createOrUpdateGraphShare and the corresponding project-share flow to
catch the propagated error and return { error: friendlyShareError(...) } without
minting or upserting a new slug.
- Around line 125-129: Update revokeShare to request the deleted share row from
the shares delete operation and treat an empty returned result as an error.
Preserve the existing Supabase-unavailable and database-error handling, and only
return success when the targeted share is actually deleted so ShareModal does
not clear state for an unresolved revoke.

In `@services/sync.ts`:
- Around line 262-263: Update syncCloud() to remove tombstone entries whose ids
are present in finalGraphs or finalProjects immediately after loadTombstones()
and before generating tombstone rows. Preserve tombstones for ids absent from
both collections so the catch-all phase still queues genuinely deleted records.

In `@supabase/schema.sql`:
- Around line 293-313: Update public.purge_versions_for_deleted_graph so its
condition handles INSERT events without accessing OLD: treat inserts with
new.deleted = true as eligible, while retaining the existing transition check
for UPDATE events. Preserve the deletion of matching graph_versions and the
trigger configuration.

---

Outside diff comments:
In `@App.tsx`:
- Around line 690-721: Update handleImportData so fetchCloudIds() runs before
setGraphs and setProjects, then record tombstones for cloud-only graph and
project IDs using the imported ID sets. Remove the trailing fetchCloudIds block
after the state updates, preserving the existing best-effort null handling.

In `@components/SettingsPage.tsx`:
- Around line 394-404: Update the provider initialization in SettingsPage so a
persisted "hosted" value is replaced with a supported BYOK provider when
cloudConfigured is false. Ensure this fallback runs on mount or when
configuration is loaded, while preserving the existing provider selection when
hosted is available and keeping the select, key fields, and model sections
synchronized.

---

Minor comments:
In `@api/_lib/polar.ts`:
- Around line 32-40: Update the origin-selection logic around the origin header
and fallback URL so payment redirects never use an arbitrary caller-controlled
Origin. Require the configured APP_URL (or an equivalent fixed allowlist) for
non-local environments, while preserving direct localhost/loopback handling for
explicit local development; ensure authenticated checkout cannot fall back to an
untrusted request origin.

In `@api/delete-account.ts`:
- Around line 10-14: Update the database schema migration for
graph_versions.graph_id to add a foreign key referencing graphs with ON DELETE
CASCADE. Preserve the non-null constraint and ensure existing graph_versions
rows and their related indexes are removed when a graph is deleted, matching the
cascade behavior described in the delete-account documentation.

In `@api/generate.ts`:
- Around line 156-183: Update the model call in the generate handler’s try/catch
to use a short, explicit timeout with an abort signal, ensuring a hanging
ai.models.generateContent request rejects and reaches the existing refund path.
Preserve the current error logging, refund_ai_usage call, and 502 response
behavior for timeout failures.

In `@App.tsx`:
- Around line 822-828: Replace the title-based userNamed inference in the
generation flow with an explicit flag recorded by renameGraph. Set that flag
only when the user renames the graph, and use it when deciding whether to
preserve activeGraph.title, so AI-generated titles remain updateable until an
explicit rename occurs.

In `@components/AccountSection.tsx`:
- Around line 82-106: The checkout poll attempt counter in the useEffect must
persist when refreshProfile changes and the effect restarts. Move attempts to a
useRef (or equivalent stable state), reset it when a new checkout begins,
increment the stable value during polling, and use it for the delayed fallback
while preserving the existing cleanup behavior.

In `@components/AuthModal.tsx`:
- Around line 165-196: Add accessible names to both email and password inputs in
the AuthModal form, and to the reset-form email input near the reset flow. Use
clear aria-labels or existing visually hidden labels, while preserving the
current input behavior and autocomplete settings.

In `@components/ComponentLibrary.tsx`:
- Around line 247-271: Update the custom template rows rendered in
filteredCustom.map to be keyboard accessible: add button semantics, make each
row focusable, and handle Enter and Space by invoking addCustomTemplate(t),
while preserving the nested delete button’s stopPropagation behavior.
- Line 213: Update handleSaveTemplate in ComponentLibrary so it immediately
returns when a save is already in progress or the template name is empty,
ensuring both button clicks and the Enter-key onKeyDown path share the same
guard and cannot create duplicate or blank templates.

In `@components/LegalPages.tsx`:
- Around line 37-39: Update the Last updated paragraph and footer in the
LegalPages component to use text-gray-500 or a darker text color instead of
text-gray-400, preserving the existing layout and other styling.

In `@components/SharedViewPage.tsx`:
- Around line 168-174: Remove the whitespace expression following the “IB
EconGraph AI” button in the footer of SharedViewPage, so the comma renders
immediately after the linked text while preserving the intended spacing after
the comma.

In `@docs/BACKEND_SETUP.md`:
- Around line 163-165: Reconcile the grace-period value described near the
webhook entitlement documentation with the implemented billing behavior and the
corresponding CHANGELOG entry. Update the conflicting documentation so the
stated grace period is consistent across both references, preserving the
existing entitlement and renewal semantics.
- Around line 101-105: Update the environment-variable code fences in
BACKEND_SETUP.md, including the blocks around VERTEX_API_KEY and the additional
referenced blocks, to specify the dotenv language. Preserve their existing
contents and formatting while ensuring every affected fence is marked for dotenv
syntax.

In `@scripts/seo-content.mjs`:
- Around line 454-458: Update the licensing statement in the faq array of
seo-content.mjs to reference AGPL-3.0 instead of MIT, keeping the rest of the
classroom-use answer unchanged.

In `@services/diagramPrompt.ts`:
- Around line 13-15: Correct the “Shared Coordinates (CRITICAL)” prompt text in
diagramPrompt.ts by changing the ungrammatical “If an equilibrium point E is at
(50, 50), ensuring…” sentence to use “ensure” as the main instruction, while
preserving its coordinate-matching requirements.

In `@services/keyObfuscation.ts`:
- Around line 7-9: Update obfuscateKey to encode the key as UTF-8 before passing
it to btoa, preventing InvalidCharacterError for Unicode input. Update the
corresponding decode path to reverse the UTF-8 encoding, while preserving
identical decoding for existing ASCII-only stored keys.

---

Nitpick comments:
In @.github/workflows/db-keepalive.yml:
- Around line 37-40: Update the curl invocation in the keepalive workflow to
include a finite maximum request duration using curl’s timeout option, ensuring
a hung Supabase request cannot stall the job for the workflow’s full timeout
period while preserving the existing URL, headers, and HTTP-status capture.
- Around line 12-16: Augment the scheduled keepalive defined by the workflow’s
on.schedule configuration with a redundant external ping or equivalent
missed-run alert, targeting the same PostgREST keepalive endpoint. Ensure the
fallback preserves the requirement that the database is contacted within every
seven-day window even if GitHub disables or delays scheduled workflow runs.

In `@api/generate.ts`:
- Around line 40-72: Hoist the resolved AI client out of the request path by
memoizing the result of resolveAiClient() at module scope. Ensure
GOOGLE_SERVICE_ACCOUNT_JSON is parsed and GoogleGenAI is constructed once per
instance, while preserving the existing environment-priority selection and null
behavior; reuse the cached client wherever resolveAiClient() is currently
invoked.

In `@api/portal.ts`:
- Around line 26-37: Update the error handling around getPolar and
polar.customerSessions.create so configuration failures such as a missing
POLAR_ACCESS_TOKEN return the established 503 “not configured on this
deployment” response, while genuine missing-customer errors retain the existing
404 response. Follow the existing handling pattern used by checkout.ts,
delete-account.ts, or usage.ts and keep the portal success response unchanged.

In `@components/ComparePage.tsx`:
- Around line 147-191: Update the comparison table in the ROWS mapping to render
each row label as a row header with scope="row", and add scope="col" to every
header cell in the table header. Make the overflow-x-auto wrapper
keyboard-focusable, preserving its horizontal scrolling behavior for keyboard
users.

In `@components/ComponentLibrary.tsx`:
- Around line 70-79: Add a per-effect cancellation flag in the useEffect that
loads custom templates, set it during cleanup, and only apply
fetchCustomTemplates results while the effect is still active. Preserve the
immediate signed-out reset and cached-template behavior, ensuring stale
responses cannot overwrite newer state.

In `@components/LandingPage.tsx`:
- Around line 750-761: Update the LandingPage footer Privacy and Terms links to
use the SPA callback contract instead of hard-coded href navigation: accept
onOpenPrivacy and onOpenTerms props in LandingPage, then invoke the
corresponding callbacks from those links while preserving their existing styling
and labels. Ensure the parent passes both callbacks into LandingPage.

In `@components/ShareModal.tsx`:
- Around line 33-43: Key both modal load effects on the graph ID rather than the
changing graph object. In components/ShareModal.tsx lines 33-43, use graph?.id
in the effect logic and dependency array; in components/CloudHistoryModal.tsx
lines 33-41, do the same and reset versions when the graph ID changes.

In `@package.json`:
- Line 39: Add the Vercel CLI package to package.json's devDependencies so the
existing dev:api script can run without requiring a global installation. Use the
project's existing dependency versioning conventions and avoid changing the
script itself.

In `@services/billing.ts`:
- Around line 3-24: Update callBillingEndpoint and deleteAccount to enforce a
finite timeout on their network requests, using the shared timeout approach
referenced for services/hostedAi.ts. Ensure timed-out requests abort and flow
through the existing error handling so callers do not remain loading
indefinitely.

In `@services/hostedAi.ts`:
- Around line 21-33: Add a request timeout using an AbortController to both
generateDiagramDataHosted and fetchHostedUsage, passing its signal to each fetch
call and aborting after the established timeout interval. Ensure timers are
cleaned up when requests complete, while preserving the existing
connection-error handling.
- Line 1: Add a shared AbortController-based timeout helper and use it for the
fetch calls in hostedAi generation and usage flows, plus billing’s
callBillingEndpoint and deleteAccount. Ensure each request aborts after the
configured timeout while preserving existing request handling and error
propagation.

In `@services/sync.ts`:
- Around line 264-267: Update the syncCloud data-fetch flow around the graphRes
and projectRes queries to use two phases: initially select only id,
last_modified, and deleted for remote diffing, then fetch the full metadata and
data payload only for rows determined to require pulling. Preserve existing
handling for unchanged, deleted, and locally modified records while avoiding
full data transfers on every debounced sync round.

In `@services/useCloudSync.ts`:
- Around line 87-93: Store the 500 ms timeout created in the finally block of
runSync in the existing timerRef (or a dedicated timeout ref), and have the
unmount/sign-out cleanup clear that stored timer before resetting it. Keep the
rerunRef behavior unchanged while ensuring runSync cannot be triggered by this
delayed callback after cleanup.

In `@supabase/schema.sql`:
- Around line 458-464: The ai_usage upsert does not enforce p_limit on the
initial insert. Update the insert/upsert flow around the ai_usage conflict
handler so p_limit <= 0 prevents inserting a first monthly usage record and
follows the existing quota-exceeded behavior, while preserving the current
increment guard for existing records.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c716cb2-5de2-4056-a24a-c2e9b6b87059

📥 Commits

Reviewing files that changed from the base of the PR and between e73b1fb and 9a1ba2e.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (54)
  • .env.example
  • .github/workflows/db-keepalive.yml
  • .github/workflows/update-supporters.yml
  • .gitignore
  • App.tsx
  • CHANGELOG.md
  • README.md
  • api/_lib/polar.ts
  • api/_lib/supabaseAdmin.ts
  • api/checkout.ts
  • api/delete-account.ts
  • api/generate.ts
  • api/portal.ts
  • api/usage.ts
  • api/webhooks/polar.ts
  • components/AccountSection.tsx
  • components/AuthModal.tsx
  • components/CloudHistoryModal.tsx
  • components/ComparePage.tsx
  • components/ComponentLibrary.tsx
  • components/LandingPage.tsx
  • components/LegalPages.tsx
  • components/PricingPage.tsx
  • components/SettingsPage.tsx
  • components/ShareModal.tsx
  • components/SharedViewPage.tsx
  • docs/BACKEND_SETUP.md
  • index.html
  • index.tsx
  • package.json
  • public/sitemap.xml
  • scripts/generate-seo-pages.mjs
  • scripts/seo-content.mjs
  • scripts/update-supporters.mjs
  • services/ai.ts
  • services/aiProvider.ts
  • services/auth.tsx
  • services/billing.ts
  • services/cloudErrors.ts
  • services/customTemplates.ts
  • services/diagramPrompt.ts
  • services/entitlement.ts
  • services/gemini.ts
  • services/hostedAi.ts
  • services/keyObfuscation.ts
  • services/openrouter.ts
  • services/shares.ts
  • services/supabaseClient.ts
  • services/sync.ts
  • services/useCloudSync.ts
  • supabase/schema.sql
  • vercel.json
  • vite-env.d.ts
  • vite.config.ts
💤 Files with no reviewable changes (1)
  • public/sitemap.xml

Comment thread .github/workflows/update-supporters.yml
Comment thread api/generate.ts Outdated
Comment thread api/generate.ts
Comment thread api/webhooks/polar.ts Outdated
Comment thread api/webhooks/polar.ts Outdated
Comment thread components/LegalPages.tsx Outdated
Comment thread services/shares.ts
Comment thread services/shares.ts
Comment thread services/sync.ts
Comment thread supabase/schema.sql

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 55 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="components/SettingsPage.tsx">

<violation number="1" location="components/SettingsPage.tsx:402">
P2: Removing cloud configuration can leave a persisted `hosted` provider active even though this option is no longer rendered. Reset or fall back to a BYOK provider when cloud is unavailable so zero-config deployments do not block AI generation for users with existing local storage.</violation>
</file>

<file name="services/diagramPrompt.ts">

<violation number="1" location="services/diagramPrompt.ts:121">
P2: The new shared module services/diagramPrompt.ts exports DIAGRAM_SYSTEM_INSTRUCTION and buildHistoryContext as the single source of truth for all AI providers, but services/openrouter.ts still maintains its own identical inline copies of both. This creates a drift risk: any prompt or formatting update to the shared module will silently leave OpenRouter behind. Import the shared exports in services/openrouter.ts and remove the inline duplicates.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread api/delete-account.ts Outdated
Comment thread App.tsx
Comment thread api/delete-account.ts Outdated
Comment thread services/useCloudSync.ts
Comment thread api/checkout.ts
Comment thread App.tsx Outdated
Comment thread scripts/generate-seo-pages.mjs
Comment thread components/AccountSection.tsx
Comment thread services/auth.tsx
Comment thread supabase/schema.sql
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.
…g 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 55 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread api/delete-account.ts Outdated
Comment thread supabase/schema.sql Outdated
Comment thread supabase/schema.sql
Comment thread scripts/generate-seo-pages.mjs
Comment thread api/usage.ts Outdated
Comment thread api/generate.ts Outdated
Comment thread App.tsx
Comment thread components/LandingPage.tsx Outdated
Comment thread api/webhooks/polar.ts
Comment thread api/generate.ts Outdated
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
App.tsx (1)

271-336: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Scope-switch effect resets historyIndex state but not historyIndexRef, enabling an out-of-bounds undo crash.

On an account switch, historyRef.current is reset to a 1-item array ([blank]) and historyIndex state goes to 0, but historyIndexRef.current (used by undo/redo) is left untouched. If it was left > 0 from the previous scope's session, pressing Ctrl+Z right after the switch (before any edit or graph open) passes undo()'s historyIndexRef.current > 0 guard, then indexes historyRef.current[nextIndex] past the new array's bounds, returning undefined into setCurrentDiagram. That crashes DiagramRenderer once it accesses fields on the diagram. The keyboard shortcut for undo (line 565-568) calls undo() unconditionally, with no canUndo/state-based guard, so this path is directly reachable.

🐛 Proposed fix
     setActiveGraphId(null);
     const blank = { ...EMPTY_DIAGRAM };
     setCurrentDiagram(blank);
     setHistory([blank]);
     historyRef.current = [blank];
     setHistoryIndex(0);
+    historyIndexRef.current = 0;
     setLoadedScope(storeScope);
     setHasInitialized(true);
   }, [storeScope, loadedScope]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@App.tsx` around lines 271 - 336, Update the scope-switch reset logic in the
storeScope effect to also set historyIndexRef.current to 0 alongside
historyRef.current, historyIndex, and the blank diagram. Ensure undo and redo
observe the new one-item history immediately after switching accounts and cannot
index beyond the reset history.
vite.config.ts (1)

6-84: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Tighten dev API route containment before module resolution.

/api/../../../../secret.ts resolves to absolute paths outside api/, including /secret.ts.ts, because rel still contains .. and path.join() can escape through that segment. Normalize the extracted route, reject ../consecutive slashes before building variants, and only allow segments under api/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vite.config.ts` around lines 6 - 84, Harden devApiPlugin’s route extraction
before constructing variants: normalize the pathname, reject traversal segments,
empty/consecutive segments, and any route that resolves outside the root api
directory. Apply this validation before fs.existsSync or ssrLoadModule,
returning a 404 for invalid routes while preserving valid file and index route
resolution.
🧹 Nitpick comments (1)
App.tsx (1)

187-189: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Ref mutated directly during render (loadedScopeRef, graphsRef).

Both loadedScopeRef.current = loadedScope; (line 189) and graphsRef.current = graphs; (line 799) assign to a ref in the component body instead of an effect. React can discard or replay a render pass, so these assignments can leak stale values from an aborted render. The file already handles this correctly elsewhere — activeGraphIdRef/currentDiagramRef are synced via dedicated useEffects (lines 334-335) — making these two an inconsistency rather than a deliberate exception.

♻️ Proposed fix
-  const loadedScopeRef = useRef<string | null>(null);
-  loadedScopeRef.current = loadedScope;
+  const loadedScopeRef = useRef<string | null>(null);
+  useEffect(() => { loadedScopeRef.current = loadedScope; }, [loadedScope]);
-  const graphsRef = useRef(graphs);
-  graphsRef.current = graphs;
+  const graphsRef = useRef(graphs);
+  useEffect(() => { graphsRef.current = graphs; }, [graphs]);

Also applies to: 798-800

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@App.tsx` around lines 187 - 189, Move the loadedScopeRef.current assignment
out of the App component render body and synchronize it in a dedicated useEffect
keyed to loadedScope, matching the existing activeGraphIdRef/currentDiagramRef
pattern. Apply the same change to graphsRef.current, using an effect keyed to
graphs, and remove both direct render-time mutations.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@services/localStore.ts`:
- Around line 55-65: Update writeRaw to report localStorage failures to callers
instead of swallowing them after logging, and ensure the write/remove operation
is confirmed successful. In migrateLegacyStore and adoptScope, clear the source
namespace only after the destination write succeeds; preserve the source data
when any required write fails.

In `@supabase/schema.sql`:
- Around line 417-448: Update purge_shares_for_deleted_content() to guard OLD
access by checking TG_OP before evaluating old.deleted, so INSERT executions
only inspect NEW and return without deleting shares unless the operation is an
update transitioning deleted to true. Preserve the existing graph/project share
deletion branches and trigger definitions.

---

Outside diff comments:
In `@App.tsx`:
- Around line 271-336: Update the scope-switch reset logic in the storeScope
effect to also set historyIndexRef.current to 0 alongside historyRef.current,
historyIndex, and the blank diagram. Ensure undo and redo observe the new
one-item history immediately after switching accounts and cannot index beyond
the reset history.

In `@vite.config.ts`:
- Around line 6-84: Harden devApiPlugin’s route extraction before constructing
variants: normalize the pathname, reject traversal segments, empty/consecutive
segments, and any route that resolves outside the root api directory. Apply this
validation before fs.existsSync or ssrLoadModule, returning a 404 for invalid
routes while preserving valid file and index route resolution.

---

Nitpick comments:
In `@App.tsx`:
- Around line 187-189: Move the loadedScopeRef.current assignment out of the App
component render body and synchronize it in a dedicated useEffect keyed to
loadedScope, matching the existing activeGraphIdRef/currentDiagramRef pattern.
Apply the same change to graphsRef.current, using an effect keyed to graphs, and
remove both direct render-time mutations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: db0adcd3-8645-4038-9fdb-663162a215a8

📥 Commits

Reviewing files that changed from the base of the PR and between 9a1ba2e and 6d56967.

📒 Files selected for processing (17)
  • .github/workflows/update-supporters.yml
  • App.tsx
  • CHANGELOG.md
  • api/delete-account.ts
  • api/generate.ts
  • api/usage.ts
  • api/webhooks/polar.ts
  • components/LandingPage.tsx
  • package.json
  • scripts/generate-seo-pages.mjs
  • scripts/seo-content.mjs
  • services/hostedAi.ts
  • services/localStore.ts
  • services/shares.ts
  • services/useCloudSync.ts
  • supabase/schema.sql
  • vite.config.ts
🚧 Files skipped from review as they are similar to previous changes (11)
  • api/usage.ts
  • scripts/seo-content.mjs
  • package.json
  • CHANGELOG.md
  • api/generate.ts
  • services/useCloudSync.ts
  • services/hostedAi.ts
  • scripts/generate-seo-pages.mjs
  • api/webhooks/polar.ts
  • components/LandingPage.tsx
  • services/shares.ts

Comment thread services/localStore.ts Outdated
Comment thread supabase/schema.sql

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread services/localStore.ts Outdated
Comment thread services/localStore.ts
Comment thread App.tsx Outdated
Sukarth added 7 commits July 28, 2026 12:16
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.
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.
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.
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.
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.
…py 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.
…ate 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 <td>, 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 <button> (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).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
components/LandingPage.tsx (1)

225-236: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use crawlable links for public Pricing and Compare routes.

These public SEO pages are rendered as buttons, while Privacy and Terms use RouteLink. Use RouteLink with /pricing and /compare so crawlers, keyboard users, and new-tab navigation retain normal link behavior.

  • components/LandingPage.tsx#L225-L236: replace the top-navigation buttons with RouteLink instances.
  • components/LandingPage.tsx#L764-L775: replace the footer buttons with the same RouteLink instances.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/LandingPage.tsx` around lines 225 - 236, Replace the Pricing and
Compare buttons in the top navigation with RouteLink instances targeting
/pricing and /compare, preserving their styling and labels; apply the same
replacement to the footer Pricing and Compare controls in
components/LandingPage.tsx at lines 764-775. Remove the onOpenPricing and
onOpenCompare handlers from these public navigation elements.
api/usage.ts (2)

43-44: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Capture the usage month once per request.

The month used in the database query can roll over before the response is built, causing a previous month’s used value to be labeled with the new month. Store const month = currentUsageMonth() once and reuse it for both the query and response.

Proposed fix
+    const month = currentUsageMonth();
     const [profile, usageResult] = await Promise.all([
...
-            .eq('month', currentUsageMonth())
+            .eq('month', month)
...
-        month: currentUsageMonth(),
+        month,

Also applies to: 57-62

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/usage.ts` around lines 43 - 44, Capture the result of currentUsageMonth()
once at the start of the request, then reuse that month variable in the database
query and response construction. Update the usage handling around the query and
the response fields near lines 57–62 so the queried month and returned month
cannot diverge.

39-45: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wrap delayed Supabase admin initialization with 503 handling.

getSupabaseAdmin() throws synchronously when SUPABASE_URL / SUPABASE_SECRET_KEY are missing, and in api/usage.ts that happens before the .catch on the auth path plus the 503 handling for usageResult.error. Move this initialization inside a try and return the existing “Usage service is temporarily unavailable” response on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/usage.ts` around lines 39 - 45, Update the usage-loading flow around
getSupabaseAdmin() so its synchronous initialization occurs inside a try/catch,
returning the existing “Usage service is temporarily unavailable” 503 response
when initialization or the usage query fails. Preserve the current auth-path
handling and successful usage response behavior.
🧹 Nitpick comments (2)
package.json (1)

39-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the Vercel CLI used by dev:api.

npx vercel currently resolves an unpinned CLI version at invocation time because there is no local vercel dependency. Add a validated vercel dev dependency and invoke vercel dev directly from package.json for reproducible local API behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 39, Add a validated version of the vercel package to
devDependencies, then update the dev:api script to invoke the locally installed
vercel CLI directly instead of using npx. Preserve the existing dev command and
--listen 4000 option.
services/localStore.ts (1)

114-128: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Close a connection that arrives after the fallback.

When onblocked or the timeout wins, req.onsuccess can still fire later and hand back a live IDBDatabase nobody holds. An open connection is exactly what blocks another tab's upgrade, so the fallback can end up perpetuating the condition it's reacting to.

♻️ Close the orphaned connection
-            req.onsuccess = () => done(req.result);
+            req.onsuccess = () => {
+                if (settled) { req.result.close(); return; }
+                done(req.result);
+            };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/localStore.ts` around lines 114 - 128, Update the IndexedDB request
handling around the timeout and onblocked fallback so completion is coordinated
and late req.onsuccess results are closed when done has already resolved. Ensure
any database returned after the fallback is closed immediately, while preserving
the existing successful connection behavior when it wins.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/webhooks/polar.ts`:
- Around line 97-172: Guard the Date.parse results in decideEntitlement for both
current?.pro_until and current?.polar_event_at, treating non-finite or invalid
parses as absent values. Ensure malformed pro_until cannot flow into Math.max or
new Date(...).toISOString(), and malformed polar_event_at does not disable
event-order validation or cause an exception.

In `@services/localStore.ts`:
- Around line 299-306: Update readCollection to inspect the IdB result’s success
status before returning data: preserve the array result only when result.ok is
true, and propagate the underlying read failure otherwise instead of returning
an empty array. Ensure readScope and its callers, including autosave,
scopeHasContent, and adoptScope, retain or surface that failure so failed reads
cannot be treated as empty scopes or trigger destructive writes.
- Around line 226-247: Update the migration flow around the loop in
migrateToIndexedDb to track whether every namespace was processed successfully,
marking the pass unsuccessful when idbGet fails or idbPut fails and leaves
localStorage intact. Call writeVersion(VERSION_INDEXEDDB) only after a clean
pass; preserve retryable source data and avoid recording completion when any
namespace was skipped.

In `@types.ts`:
- Around line 87-93: Update the Graph version fingerprint logic in
services/sync.ts to include the titleSetByUser field alongside diagramData,
title, and caption. Ensure changes to this behavior-affecting flag produce a
distinct cloud snapshot even when the other fingerprinted fields are unchanged.

---

Outside diff comments:
In `@api/usage.ts`:
- Around line 43-44: Capture the result of currentUsageMonth() once at the start
of the request, then reuse that month variable in the database query and
response construction. Update the usage handling around the query and the
response fields near lines 57–62 so the queried month and returned month cannot
diverge.
- Around line 39-45: Update the usage-loading flow around getSupabaseAdmin() so
its synchronous initialization occurs inside a try/catch, returning the existing
“Usage service is temporarily unavailable” 503 response when initialization or
the usage query fails. Preserve the current auth-path handling and successful
usage response behavior.

In `@components/LandingPage.tsx`:
- Around line 225-236: Replace the Pricing and Compare buttons in the top
navigation with RouteLink instances targeting /pricing and /compare, preserving
their styling and labels; apply the same replacement to the footer Pricing and
Compare controls in components/LandingPage.tsx at lines 764-775. Remove the
onOpenPricing and onOpenCompare handlers from these public navigation elements.

---

Nitpick comments:
In `@package.json`:
- Line 39: Add a validated version of the vercel package to devDependencies,
then update the dev:api script to invoke the locally installed vercel CLI
directly instead of using npx. Preserve the existing dev command and --listen
4000 option.

In `@services/localStore.ts`:
- Around line 114-128: Update the IndexedDB request handling around the timeout
and onblocked fallback so completion is coordinated and late req.onsuccess
results are closed when done has already resolved. Ensure any database returned
after the fallback is closed immediately, while preserving the existing
successful connection behavior when it wins.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fb36f763-848c-43e5-a900-f04e9e90a866

📥 Commits

Reviewing files that changed from the base of the PR and between 6d56967 and 4cffab0.

📒 Files selected for processing (43)
  • .github/workflows/db-keepalive.yml
  • App.tsx
  • CHANGELOG.md
  • api/_lib/polar.ts
  • api/checkout.ts
  • api/delete-account.ts
  • api/generate.ts
  • api/portal.ts
  • api/usage.ts
  • api/webhooks/polar.ts
  • components/AccountSection.tsx
  • components/AuthModal.tsx
  • components/CloudHistoryModal.tsx
  • components/ComparePage.tsx
  • components/ComponentLibrary.tsx
  • components/LandingPage.tsx
  • components/LegalPages.tsx
  • components/PricingPage.tsx
  • components/ShareModal.tsx
  • components/SharedViewPage.tsx
  • docs/BACKEND_SETUP.md
  • index.html
  • package.json
  • scripts/generate-seo-pages.mjs
  • scripts/seo-content.mjs
  • scripts/update-supporters.mjs
  • services/aiProvider.ts
  • services/auth.tsx
  • services/billing.ts
  • services/diagramPrompt.ts
  • services/entitlement.ts
  • services/hostedAi.ts
  • services/httpTimeout.ts
  • services/keyObfuscation.ts
  • services/localStore.ts
  • services/openrouter.ts
  • services/shares.ts
  • services/sync.ts
  • services/useCloudSync.ts
  • supabase/schema.sql
  • types.ts
  • vercel.json
  • vite.config.ts
🚧 Files skipped from review as they are similar to previous changes (31)
  • services/keyObfuscation.ts
  • api/checkout.ts
  • .github/workflows/db-keepalive.yml
  • scripts/seo-content.mjs
  • components/LegalPages.tsx
  • services/aiProvider.ts
  • scripts/update-supporters.mjs
  • services/billing.ts
  • components/PricingPage.tsx
  • services/hostedAi.ts
  • api/portal.ts
  • CHANGELOG.md
  • api/_lib/polar.ts
  • components/AuthModal.tsx
  • components/ComponentLibrary.tsx
  • components/CloudHistoryModal.tsx
  • api/generate.ts
  • components/SharedViewPage.tsx
  • components/ComparePage.tsx
  • components/ShareModal.tsx
  • vite.config.ts
  • services/openrouter.ts
  • services/useCloudSync.ts
  • docs/BACKEND_SETUP.md
  • services/shares.ts
  • supabase/schema.sql
  • scripts/generate-seo-pages.mjs
  • services/sync.ts
  • services/auth.tsx
  • components/AccountSection.tsx
  • App.tsx

Comment thread api/webhooks/polar.ts
Comment thread services/localStore.ts Outdated
Comment thread services/localStore.ts Outdated
Comment thread types.ts
Comment on lines +87 to +93
/**
* 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 '\bversionFingerprint\b|\btitleSetByUser\b' --glob '*.ts' --glob '*.tsx' .

Repository: Sukarth/IB-EconGraph-AI

Length of output: 3583


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== services/sync.ts relevant sections =="
sed -n '1,130p' services/sync.ts
sed -n '480,535p' services/sync.ts

echo
echo "== app graph mutation/rename sections =="
sed -n '400,440p' App.tsx
sed -n '600,630p' App.tsx
sed -n '825,842p' App.tsx

echo
echo "== contentHash definition/usages =="
rg -n -C2 'function contentHash|const contentHash|contentHash\(' --glob '*.ts' --glob '*.tsx' .

echo
echo "== structural facts about Graph shape and versionFingerprint inputs =="
python3 - <<'PY'
from pathlib import Path
sync = Path('services/sync.ts').read_text()
types = Path('types.ts').read_text()
app = Path('App.tsx').read_text()
print('versionFingerprint references titleSetByUser:', 'titleSetByUser' in sync[sync.index('function versionFingerprint'):sync.index('function versionFingerprint')+1000])
print('versionFingerprint JSON stringifies:', 'JSON.stringify({' in sync)
start = sync.index('function versionFingerprint')
end = sync.index('}', start) + 1
print(sync[start:end])
PY

Repository: Sukarth/IB-EconGraph-AI

Length of output: 12727


Include titleSetByUser in the version fingerprint.

Graph can change this behavior-affecting flag without changing diagramData, title, or caption for existing graphs, but services/sync.ts now only fingerprints those three fields. This allows cloud versioning to skip a snapshot for the same content plus a renamed/user-set graph flag.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@types.ts` around lines 87 - 93, Update the Graph version fingerprint logic in
services/sync.ts to include the titleSetByUser field alongside diagramData,
title, and caption. Ensure changes to this behavior-affecting flag produce a
distinct cloud snapshot even when the other fingerprinted fields are unchanged.

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.
readCollection discarded the `ok` flag that IdbResult exists to carry, so a
failed IndexedDB read came back as []. Three things then acted on that
emptiness as if it were fact: the autosave effects wrote the empty arrays
over the stored records, scopeHasContent reported nothing worth keeping,
and adoptScope copied nothing into the destination and cleared the source
anyway, which is precisely the loss its doc comment claims to prevent.

readScope now returns `ok`. adoptScope refuses to move a namespace it
could not read, scopeHasContent answers false rather than guessing, and
App leaves `loadedScope` unset so the save effects stay parked for the
session. A banner says saving is off rather than letting the session look
normal while nothing persists.

Separately, migrateToIndexedDb stamped VERSION_INDEXEDDB even when a
namespace had been skipped. Both skip paths deliberately leave the
localStorage source in place to be retried, but the stamp ended the
retries while reads had already moved to IndexedDB, so one transient
failure orphaned that namespace permanently. Only stamp a clean pass.

Also guard the two Date.parse calls in decideEntitlement. Its
CurrentBillingState is a plain interface, not a row type, so nothing
guarantees the strings parse; a NaN reaching Math.max made
new Date(...).toISOString() throw, and a webhook that throws is one Polar
retries forever.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 issues found across 43 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="api/checkout.ts">

<violation number="1" location="api/checkout.ts:61">
P1: A second checkout can still be created before the first payment webhook updates `profiles`, so separate tabs/devices can create parallel subscriptions and double-charge. The client-side disabled button is not a cross-request lock; retain an authoritative Polar check or add a server-side pending-checkout lock before creating a session.</violation>
</file>

<file name="services/diagramPrompt.ts">

<violation number="1" location="services/diagramPrompt.ts:160">
P2: BYOK Gemini and OpenRouter generations still bypass this validator and can pass partial model output into the renderer. Apply `diagramShapeError` after parsing in both provider implementations before returning `DiagramData`.</violation>

<violation number="2" location="services/diagramPrompt.ts:213">
P2: A malformed annotation without `label` passes validation, then `FormattedText` calls `text.length` on `undefined` while rendering it. Validate the required annotation fields, not only its coordinates.</violation>
</file>

<file name="api/_lib/polar.ts">

<violation number="1" location="api/_lib/polar.ts:28">
P3: Checkout testing through `*.ngrok.app` will fail after Polar redirects because this allowlist accepts the origin while Vite rejects its Host header. Add `.ngrok.app` to `server.allowedHosts` or remove it here so the two lists stay aligned.</violation>
</file>

<file name="vercel.json">

<violation number="1" location="vercel.json:5">
P2: The `functions` block in `vercel.json` configures a serverless function timeout, which violates the project convention (README.md) that `vercel.json` is for routing/rewrites only. Export a `config` object from `api/generate.ts` instead, which is supported by `@vercel/node` and co-locates the timeout with the handler.</violation>
</file>

<file name="api/delete-account.ts">

<violation number="1" location="api/delete-account.ts:54">
P2: Account deletion now fails for every non-billing deployment, including users with no subscription, because billing is queried unconditionally. Skip the Polar lookup when billing is unconfigured and the profile has no `polar_subscription_id`; retain the fail-closed path when an existing billing ID might still need revocation.</violation>
</file>

<file name="components/ComponentLibrary.tsx">

<violation number="1" location="components/ComponentLibrary.tsx:260">
P2: Screen readers may not expose the Delete control independently because it is nested inside an element with `role="button"`. Keep the row activation control separate from the delete button (for example, make only a sibling/inner non-overlapping element the keyboard-activatable control).</violation>
</file>

<file name="services/sync.ts">

<violation number="1" location="services/sync.ts:591">
P2: A revision still disappears if the graph changes before its queued retry. Persist each failed version row/snapshot, not only its ID, so retrying inserts the version whose initial write failed.</violation>
</file>

<file name="supabase/schema.sql">

<violation number="1" location="supabase/schema.sql:51">
P2: Existing subscribers keep `polar_event_at = NULL`, so their first delayed pre-migration `subscription.active` bypasses ordering and can restore access after a cancellation. Seed/reconcile the last applied event for existing billing rows before enabling this comparison.</violation>

<violation number="2" location="supabase/schema.sql:302">
P2: Concurrent multi-graph history syncs can deadlock instead of serializing: opposite graph orders hold transaction-scoped advisory locks cyclically. Acquire batch locks in a stable graph-id order (for example in a statement-level trigger) before pruning.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread App.tsx Outdated
Comment thread api/checkout.ts
return res.status(503).json({ error: 'Billing is not configured on this deployment.' });
}

// Deliberately NOT calling polar.subscriptions.list() here to close the gap

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A second checkout can still be created before the first payment webhook updates profiles, so separate tabs/devices can create parallel subscriptions and double-charge. The client-side disabled button is not a cross-request lock; retain an authoritative Polar check or add a server-side pending-checkout lock before creating a session.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/checkout.ts, line 61:

<comment>A second checkout can still be created before the first payment webhook updates `profiles`, so separate tabs/devices can create parallel subscriptions and double-charge. The client-side disabled button is not a cross-request lock; retain an authoritative Polar check or add a server-side pending-checkout lock before creating a session.</comment>

<file context>
@@ -44,15 +43,32 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
+        return res.status(503).json({ error: 'Billing is not configured on this deployment.' });
+    }
+
+    // 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
</file context>

Comment thread services/localStore.ts Outdated
Comment thread services/localStore.ts
Comment thread App.tsx
Comment thread services/sync.ts
Comment thread supabase/schema.sql
-- 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Concurrent multi-graph history syncs can deadlock instead of serializing: opposite graph orders hold transaction-scoped advisory locks cyclically. Acquire batch locks in a stable graph-id order (for example in a statement-level trigger) before pruning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/schema.sql, line 302:

<comment>Concurrent multi-graph history syncs can deadlock instead of serializing: opposite graph orders hold transaction-scoped advisory locks cyclically. Acquire batch locks in a stable graph-id order (for example in a statement-level trigger) before pruning.</comment>

<file context>
@@ -265,6 +295,12 @@ security definer
+  -- 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
</file context>

Comment thread supabase/schema.sql

-- 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Existing subscribers keep polar_event_at = NULL, so their first delayed pre-migration subscription.active bypasses ordering and can restore access after a cancellation. Seed/reconcile the last applied event for existing billing rows before enabling this comparison.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/schema.sql, line 51:

<comment>Existing subscribers keep `polar_event_at = NULL`, so their first delayed pre-migration `subscription.active` bypasses ordering and can restore access after a cancellation. Seed/reconcile the last applied event for existing billing rows before enabling this comparison.</comment>

<file context>
@@ -37,10 +37,19 @@ create table if not exists public.profiles (
 
+-- 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;
</file context>

Comment thread api/_lib/polar.ts
* `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'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Checkout testing through *.ngrok.app will fail after Polar redirects because this allowlist accepts the origin while Vite rejects its Host header. Add .ngrok.app to server.allowedHosts or remove it here so the two lists stay aligned.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/_lib/polar.ts, line 28:

<comment>Checkout testing through `*.ngrok.app` will fail after Polar redirects because this allowlist accepts the origin while Vite rejects its Host header. Add `.ngrok.app` to `server.allowedHosts` or remove it here so the two lists stay aligned.</comment>

<file context>
@@ -15,29 +15,76 @@ export function getPolar(): Polar {
+ * `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. */
</file context>

Comment thread supabase/schema.sql Outdated
Last commit left `loadedScope` unset after a failed read so the save
effects would skip. That was wrong in both directions: the effect's own
guard is `storeScope === loadedScope`, so it would re-run forever, and
`loadedScope` kept pointing at the *previous* account, which meant
switching back to it wrote this namespace's empty arrays over its real
library.

`loadedScope` now records the namespace this effect settled, whichever way
it went, and `storageUnreadable` decides separately whether saving is
allowed. Switching back to a readable account clears it and resumes.

lsGet had the same conflation the IndexedDB path did: a localStorage that
throws returned null, indistinguishable from a missing key, so the
fallback backend reported a readable empty scope. It now reports `ok`.

migrateToNamespaces also stamped its version unconditionally. A collection
that failed to copy stays in the guest keys, so ending the retries there
reclassifies an account's diagrams as work done signed out and they vanish
from the account they belong to. It now leaves the version and the owner
key alone until the whole move lands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread services/localStore.ts Outdated
fetchWithTimeout passed `signal: controller.signal` after spreading `init`,
so a caller's own AbortSignal was silently dropped. Nothing passes one
today, which is exactly why it would have been found the hard way. The two
signals are now chained, and a caller's abort propagates as their own error
rather than being relabelled a timeout.

graph_versions rows go in as one multi-row insert, so one transaction, and
the per-row cap trigger takes a transaction-scoped advisory lock per graph
as the rows land. Two devices pushing an overlapping set in their own
library order take those locks in opposite orders, which is a deadlock
rather than the serialisation the lock was added for. Sorting by graph id
gives every client one order.

FormattedText called text.length unguarded. Labels are required by the
generation schema but OpenRouter has no schema, and diagrams can be
hand-edited or predate a field, so a missing label took down the whole
canvas. It renders as nothing now. Deliberately not added to
diagramShapeError: a missing label is cosmetic, and throwing away an
otherwise good generation over it is the worse failure.
The delete button stops click propagation, but keydown still bubbles to the
row's Enter/Space handler, so deleting a template from the keyboard also
added it to the canvas. The row now ignores keys that did not originate on
the row itself.

Also record why polar_event_at is left NULL on existing rows rather than
seeded. Seeding now() would reject every event stamped before the
migration, including a slow legitimate renewal, and cutting off someone who
paid is worse than one unordered event. There is no value that
reconstructs the real last-applied time, so the choice is between two
imperfect options and this is the recoverable one.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread services/httpTimeout.ts Outdated
Comment thread services/httpTimeout.ts Outdated
The pending-version queue, the share-refresh marker and the version hash
map were all stored under one key for the whole browser. With two accounts
on one machine, B's sync read A's queued graph ids, found those graphs
missing from its own library, concluded they were deleted and dropped
them; B's successful share refresh cleared the flag A was still waiting
on. Each is now keyed by user. Existing queues are abandoned once, which
costs at most a duplicate snapshot.

Guest adoption cleared `pendingGuestAdoption` before awaiting adoptScope,
so for the length of that copy the auto-open effect saw an empty library,
created a blank diagram and synced it up. The adopted graphs then replaced
it locally while the stray row stayed in the cloud. A separate
in-flight flag now covers the await, cleared in a finally so a cancelled
run cannot strand the editor empty.

handleImportData awaited fetchCloudIds with no scope binding, so signing
in or out mid-restore dropped the backup into the other account. It is now
bound to the namespace it started in and abandons cleanly if that changed,
with every tombstone write deferred past the check so an abandoned restore
leaves no trace in either account.

Two follow-ups on the previous commit, both correctly flagged: the timeout
wrapper classified from the signals' final state, so a caller cancelling
just after the deadline was reported as their abort rather than a timeout,
and it dropped the caller's abort reason. It now records which fired first
and forwards the reason.

migrateToNamespaces ignored whether clearing the guest key succeeded, so a
failed clear left the same diagrams in both namespaces and still stamped
the version. A resumed run could not repair it either, because the
destination was populated by then and the don't-clobber guard skipped it.
It now finishes the move when the destination already holds exactly the
content being migrated.
Constraint names are unique per table, not per database, so the
idempotency guard around graph_versions_graph_id_fkey could match a
same-named constraint on some other table and skip adding the foreign key
it exists to add. Verified against Postgres 16 with a decoy constraint:
unscoped, the FK is never created; scoped by conrelid, it is.

api/_lib/polar.ts accepts .ngrok.app as a checkout redirect origin but
vite's allowedHosts did not, so that tunnel fails with "This host is not
allowed" only after Polar redirects back.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread App.tsx
`pendingGuestAdoption` is in this effect's dependency list, so setting it
false to settle the decision re-runs the effect, and the cleanup trips
`cancelled` long before adoptScope resolves. By then adoptScope has copied
the diagrams into the account and emptied the guest namespace, so
discarding its result left the work written to disk but missing from
state, and the auto-open effect then created a blank graph and autosaved
it over the top. Signing in over guest work could destroy it.

The cancel flag conflated "this effect re-ran" with "the account changed".
Only the second is a reason to withhold, and even then the diagrams are
safe on disk in the namespace they were moved into, so the check is now
against the live scope.

Predates the previous commit; the in-flight flag added there held the
auto-open effect back but did not stop the transfer being discarded.

Verified by reading rather than execution: exercising this path needs an
account with no diagrams, and creating one is not something I can do.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread App.tsx Outdated
…g refs

The previous commit decided whether to publish an adopted library by
comparing loadedScopeRef against the scope the copy started in. That ref is
synced by a passive effect, so there is a window where the incoming
account's data has already been loaded into state but the ref still names
the outgoing one. A copy resolving in that window passed the check and its
graphs were published under, and saved into, the wrong account.

The scope-load effect now invalidates the handover directly, on the line
before its first await. That runs synchronously when the switch is
detected, so the new account's data cannot be live while an old handover
still looks current. It only runs on a real scope change, since the effect
returns early when the loaded scope already matches.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread App.tsx Outdated
Third variant of the same failure. The token was cleared when a switch
started, but switching away and straight back leaves the scope-load effect
early-returning, because the namespace never stopped being the loaded one.
Nothing restored the token, so the handover was dropped, and auto-open
wrote a blank graph over diagrams adoptScope had already moved to disk.

Every version of this has tried to decide, from inside an async callback,
whether the namespace was still live, using state captured before the
await. That question cannot be answered reliably there, and answering it
wrong destroys the user's work, because by then the copy has emptied the
guest namespace and this is the only copy in memory.

So stop deciding. The callback records the result and an effect publishes
it when the live scope matches, reading state as it actually is. An account
switch mid-copy parks the diagrams until that account is back; a switch
that completes clears them, because the load effect's own read of the disk
already includes them. Auto-open waits on a parked handover only for the
namespace it belongs to, so another account still gets its first diagram.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="App.tsx">

<violation number="1" location="App.tsx:323">
P1: A rapid account switch can still discard an adopted guest library and replace it with a blank graph. Preserve a parked handover when loading a different account; only clear one whose scope is the namespace being loaded.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread App.tsx
// whatever the read (or a sync that ran meanwhile) turned up. Note this sits
// after the early return above: coming back to a namespace that never
// stopped being the loaded one does no read, and must not discard anything.
setPendingAdopted(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A rapid account switch can still discard an adopted guest library and replace it with a blank graph. Preserve a parked handover when loading a different account; only clear one whose scope is the namespace being loaded.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At App.tsx, line 323:

<comment>A rapid account switch can still discard an adopted guest library and replace it with a blank graph. Preserve a parked handover when loading a different account; only clear one whose scope is the namespace being loaded.</comment>

<file context>
@@ -312,12 +314,13 @@ export default function App() {
+    // whatever the read (or a sync that ran meanwhile) turned up. Note this sits
+    // after the early return above: coming back to a namespace that never
+    // stopped being the loaded one does no read, and must not discard anything.
+    setPendingAdopted(null);
     let cancelled = false;
     void (async () => {
</file context>
Suggested change
setPendingAdopted(null);
setPendingAdopted((adopted) => adopted?.scope === storeScope ? null : adopted);

@Sukarth
Sukarth merged commit cf4e833 into main Jul 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants