From 9aa670663894c5a27eba812bc74388abf5c6631e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 11:07:07 +0000 Subject: [PATCH 1/3] fix: require matching GitHub token cookie for auth Co-authored-by: Cole Collins --- lib/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/auth.ts b/lib/auth.ts index c0e3585..42dfb8b 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -58,7 +58,7 @@ export async function getCurrentUser(): Promise { LIMIT 1 ` const row = users[0] as AuthUser | undefined - if (row?.access_token) { + if (row?.access_token && tokenCookie === row.access_token) { return row } if (row && tokenCookie) { From 89054bd23e21e764c8890c1b88e38789bad73e2e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 11:10:26 +0000 Subject: [PATCH 2/3] fix: sync Stripe webhooks to Neon billing Co-authored-by: Cole Collins --- app/api/stripe/webhook/route.ts | 297 ++++++++++++++++---------------- 1 file changed, 150 insertions(+), 147 deletions(-) diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index cb8d6b9..deab747 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { + getSubscriptionByStripeCustomerId, + getUserByGithubId, + updateUserBilling, + upsertSubscription, +} from "@/lib/queries"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -18,27 +23,133 @@ function getStripeClient(): Stripe { return _stripe; } -let _supabase: SupabaseClient | null = null; -function getSupabaseClient(): SupabaseClient { - if (_supabase) return _supabase; - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!url || !serviceKey) { - throw new Error("Supabase env vars are not configured"); +// ─── Helpers ────────────────────────────────────────────────────────────────── + +type PlanTier = "free" | "pro"; +type DbSubscriptionStatus = "active" | "past_due" | "canceled" | "trialing"; + +function parseGithubId(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function getCustomerId( + customer: string | Stripe.Customer | Stripe.DeletedCustomer | null, +): string | null { + if (!customer) return null; + return typeof customer === "string" ? customer : customer.id; +} + +function normalizeSubscriptionStatus(status: string): DbSubscriptionStatus { + if (status === "active" || status === "trialing" || status === "past_due") { + return status; } - _supabase = createClient(url, serviceKey); - return _supabase; + return "canceled"; } -// ─── Helpers ────────────────────────────────────────────────────────────────── +function getPlanFromPriceId(priceId: string | null | undefined): PlanTier { + if (!priceId) return "free"; + const paidPriceIds = new Set( + [ + process.env.STRIPE_PRO_PRICE_ID, + process.env.STRIPE_SCALE_PRICE_ID, + process.env.STRIPE_PRICE_PRO_MONTHLY, + process.env.STRIPE_PRICE_PRO_YEARLY, + ].filter(Boolean), + ); + return paidPriceIds.has(priceId) ? "pro" : "free"; +} + +function getPlanFromSubscription(subscription: Stripe.Subscription): PlanTier { + const metadataPlan = subscription.metadata?.plan; + if (metadataPlan === "pro" || metadataPlan === "scale") { + return "pro"; + } + return getPlanFromPriceId(subscription.items.data[0]?.price.id); +} + +function getCurrentPeriodEnd(subscription: Stripe.Subscription): string | null { + const periodEnd = (subscription as unknown as { current_period_end?: number }).current_period_end; + return typeof periodEnd === "number" ? new Date(periodEnd * 1000).toISOString() : null; +} + +async function getGithubIdFromCustomer(customerId: string): Promise { + const existingSubscription = await getSubscriptionByStripeCustomerId(customerId).catch(() => null); + if (existingSubscription?.github_id) { + return existingSubscription.github_id; + } + + const customer = await getStripeClient().customers.retrieve(customerId); + if ("deleted" in customer && customer.deleted) { + return null; + } + return parseGithubId(customer.metadata?.github_id); +} + +async function resolveGithubId( + customerId: string | null, + ...metadataSources: Array +): Promise { + for (const metadata of metadataSources) { + const githubId = parseGithubId(metadata?.github_id); + if (githubId) { + return githubId; + } + } -function getPlanFromPriceId(priceId: string): string { - const priceMap: Record = { - [process.env.STRIPE_PRICE_PRO_MONTHLY || ""]: "pro", - [process.env.STRIPE_PRICE_PRO_YEARLY || ""]: "pro", - [process.env.STRIPE_PRICE_STARTER_MONTHLY || ""]: "starter", - }; - return priceMap[priceId] || "free"; + return customerId ? getGithubIdFromCustomer(customerId) : null; +} + +async function syncSubscriptionToNeon( + subscription: Stripe.Subscription, + ...metadataSources: Array +): Promise { + const customerId = getCustomerId(subscription.customer); + const githubId = await resolveGithubId(customerId, subscription.metadata, ...metadataSources); + + if (!githubId) { + throw new Error(`Unable to resolve GitHub user for Stripe subscription ${subscription.id}`); + } + + const dbStatus = normalizeSubscriptionStatus(subscription.status); + const isActive = dbStatus === "active" || dbStatus === "trialing"; + const plan = isActive ? getPlanFromSubscription(subscription) : "free"; + const priceId = subscription.items.data[0]?.price.id ?? null; + + await upsertSubscription({ + github_id: githubId, + stripe_customer_id: customerId, + stripe_subscription_id: subscription.id, + plan, + status: dbStatus, + current_period_end: getCurrentPeriodEnd(subscription), + }); + + const user = await getUserByGithubId(githubId); + if (!user) { + console.warn(`[stripe webhook] No user_auth row found for GitHub user ${githubId}`); + return; + } + + await updateUserBilling(user.id, { + stripe_customer_id: customerId, + stripe_subscription_id: subscription.id, + stripe_price_id: priceId, + plan_tier: plan, + subscription_status: subscription.status, + }); +} + +async function getSubscriptionFromCheckoutSession( + session: Stripe.Checkout.Session, +): Promise { + if (!session.subscription) { + throw new Error(`Checkout session ${session.id} did not include a subscription`); + } + return typeof session.subscription === "string" + ? getStripeClient().subscriptions.retrieve(session.subscription) + : session.subscription; } // ─── Webhook Handler ────────────────────────────────────────────────────────── @@ -77,125 +188,38 @@ export async function POST(req: NextRequest) { // ── Checkout completed (first subscription purchase) ────────────────── case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; - const customerId = session.customer as string; - const subscriptionId = session.subscription as string; - const customerEmail = session.customer_email || session.customer_details?.email; - - if (!customerEmail) break; - - await getSupabaseClient() - .from("subscriptions") - .upsert({ - stripe_customer_id: customerId, - stripe_subscription_id: subscriptionId, - email: customerEmail, - status: "active", - updated_at: new Date().toISOString(), - }, { onConflict: "email" }); - - // Also update the user's plan in the profiles table - await getSupabaseClient() - .from("profiles") - .update({ - stripe_customer_id: customerId, - subscription_status: "active", - }) - .eq("email", customerEmail); - - console.log(`✅ Checkout completed for ${customerEmail}`); + const subscription = await getSubscriptionFromCheckoutSession(session); + + await syncSubscriptionToNeon(subscription, session.metadata); + + console.log(`✅ Checkout completed for subscription ${subscription.id}`); break; } // ── Subscription created ─────────────────────────────────────────────── case "customer.subscription.created": { const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - const priceId = subscription.items.data[0]?.price.id; - const plan = getPlanFromPriceId(priceId); - - // Get customer email from Stripe - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .upsert({ - stripe_customer_id: customerId, - stripe_subscription_id: subscription.id, - email, - status: subscription.status, - plan, - current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }, { onConflict: "stripe_subscription_id" }); - - await getSupabaseClient() - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); - - console.log(`✅ Subscription created: ${email} → ${plan}`); + await syncSubscriptionToNeon(subscription); + + console.log(`✅ Subscription created: ${subscription.id}`); break; } // ── Subscription updated (plan change, renewal, etc.) ───────────────── case "customer.subscription.updated": { const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - const priceId = subscription.items.data[0]?.price.id; - const plan = getPlanFromPriceId(priceId); - - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .update({ - status: subscription.status, - plan, - current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscription.id); - - await getSupabaseClient() - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); - - console.log(`✅ Subscription updated: ${email} → ${plan} (${subscription.status})`); + await syncSubscriptionToNeon(subscription); + + console.log(`✅ Subscription updated: ${subscription.id} (${subscription.status})`); break; } // ── Subscription cancelled/deleted ──────────────────────────────────── case "customer.subscription.deleted": { const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .update({ - status: "canceled", - plan: "free", - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscription.id); + await syncSubscriptionToNeon(subscription); - await getSupabaseClient() - .from("profiles") - .update({ plan: "free", subscription_status: "canceled" }) - .eq("email", email); - - console.log(`✅ Subscription cancelled: ${email}`); + console.log(`✅ Subscription cancelled: ${subscription.id}`); break; } @@ -208,14 +232,7 @@ export async function POST(req: NextRequest) { // Update period end on renewal const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId); - await getSupabaseClient() - .from("subscriptions") - .update({ - status: "active", - current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscriptionId); + await syncSubscriptionToNeon(subscription); console.log(`✅ Invoice paid for subscription: ${subscriptionId}`); break; @@ -224,27 +241,14 @@ export async function POST(req: NextRequest) { // ── Invoice payment failed ──────────────────────────────────────────── case "invoice.payment_failed": { const invoice = event.data.object as Stripe.Invoice; - const customerId = invoice.customer as string; - - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .update({ - status: "past_due", - updated_at: new Date().toISOString(), - }) - .eq("stripe_customer_id", customerId); + const subscriptionId = (invoice as unknown as { subscription?: string }).subscription as string | undefined; - await getSupabaseClient() - .from("profiles") - .update({ subscription_status: "past_due" }) - .eq("email", email); + if (subscriptionId) { + const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId); + await syncSubscriptionToNeon(subscription); + } - console.log(`⚠️ Payment failed for: ${email}`); + console.log(`⚠️ Payment failed for subscription: ${subscriptionId ?? "unknown"}`); break; } @@ -253,8 +257,7 @@ export async function POST(req: NextRequest) { } } catch (error) { console.error("❌ Error processing webhook event:", error); - // Still return 200 so Stripe doesn't keep retrying for logic errors - return NextResponse.json({ received: true, warning: "Processing error" }, { status: 200 }); + return NextResponse.json({ error: "Webhook processing failed" }, { status: 500 }); } // 4. Always return 200 so Stripe knows we received the event From 705931e14b73d4b5f22843d7aac70898807a2db4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 11:10:59 +0000 Subject: [PATCH 3/3] fix: fail Build App on push errors Co-authored-by: Cole Collins --- app/api/build-app/route.ts | 48 +++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 0be2987..c1f4480 100644 --- a/app/api/build-app/route.ts +++ b/app/api/build-app/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import Anthropic from '@anthropic-ai/sdk' import { getCurrentUser } from '@/lib/auth' import { getAnthropicModel } from '@/lib/anthropic-model' +import { getBillingState } from '@/lib/billing' import type { AppBlueprint } from '@/lib/queries' let __anthropicClient: Anthropic | null = null @@ -140,8 +141,8 @@ async function pushFileToGitHub( ) if (!res.ok) { - const err = (await res.json()) as { message?: string } - console.warn(`[build-app] Failed to push ${path}: ${err.message}`) + const err = (await res.json().catch(() => ({}))) as { message?: string } + throw new Error(`Failed to push ${path}: ${err.message ?? res.statusText}`) } } @@ -202,8 +203,8 @@ async function pushFileToGitLab( ) if (!res.ok) { - const err = (await res.json()) as { message?: string } - console.warn(`[build-app] Failed to push ${path} to GitLab: ${err.message}`) + const err = (await res.json().catch(() => ({}))) as { message?: string } + throw new Error(`Failed to push ${path} to GitLab: ${err.message ?? res.statusText}`) } } @@ -223,6 +224,13 @@ export async function POST(request: NextRequest) { return } + const billing = await getBillingState(user) + if (!billing.canAccessPro) { + send({ step: 'error', message: 'Build This App requires an active Pro subscription.' }) + controller.close() + return + } + const body = (await request.json()) as BuildAppRequest const { platform, repoName, blueprint } = body @@ -293,19 +301,31 @@ export async function POST(request: NextRequest) { // Step 3 — push files let pushed = 0 - for (const [path, content] of fileEntries) { - if (platform === 'github') { - await pushFileToGitHub(accessToken, user.github_username, cleanRepoName, path, content) - } else if (gitlabProjectId !== null) { - await pushFileToGitLab(accessToken, gitlabProjectId, gitlabBranch, path, content) + try { + for (const [path, content] of fileEntries) { + if (platform === 'github') { + await pushFileToGitHub(accessToken, user.github_username, cleanRepoName, path, content) + } else if (gitlabProjectId !== null) { + await pushFileToGitLab(accessToken, gitlabProjectId, gitlabBranch, path, content) + } + pushed++ + send({ + step: 'pushing', + message: `Pushing files… (${pushed}/${fileEntries.length})`, + current: pushed, + total: fileEntries.length, + repoUrl, + }) } - pushed++ + } catch (e) { send({ - step: 'pushing', - message: `Pushing files… (${pushed}/${fileEntries.length})`, - current: pushed, - total: fileEntries.length, + step: 'error', + message: e instanceof Error ? e.message : 'Failed to push generated files.', + repoUrl, + filesCreated: pushed, }) + controller.close() + return } send({