From fa90b5e5197ad5153e2c2d9df8bc136f35b75479 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Tue, 18 Aug 2026 23:58:35 +0530 Subject: [PATCH 1/2] feat(billing): integrate Dodo payments for subscription plans - Add Dodo checkout, portal, and subscription APIs - Add Dodo webhook handler and billing-auth middleware - Track dodo customer/subscription fields in user schema and webhook events table - Add shared paid plans with currency and cycle support - Wire billing UI into web dashboard, pricing page, and mobile user tab - Enforce tier tunnel limits based on active subscription in tunnel-server --- apps/mobile/app/(tabs)/user.tsx | 29 + apps/mobile/lib/billing.ts | 25 + apps/web/.env.example | 9 + apps/web/drizzle/0000_dodo_billing.sql | 13 + apps/web/drizzle/meta/0000_snapshot.json | 737 ++++++++++++++++++ apps/web/drizzle/meta/_journal.json | 13 + apps/web/package.json | 1 + .../web/src/app/api/billing/checkout/route.ts | 42 + apps/web/src/app/api/billing/confirm/route.ts | 39 + apps/web/src/app/api/billing/portal/route.ts | 17 + .../src/app/api/billing/subscription/route.ts | 14 + apps/web/src/app/api/webhooks/dodo/route.ts | 70 ++ apps/web/src/app/billing/cancelled/page.tsx | 17 + apps/web/src/app/billing/success/page.tsx | 41 + apps/web/src/app/dashboard/page.tsx | 51 ++ apps/web/src/app/login/page.tsx | 2 +- apps/web/src/app/pricing/page.tsx | 150 +++- apps/web/src/lib/billing-auth.ts | 17 + apps/web/src/lib/db/schema.ts | 12 + apps/web/src/lib/dodo.ts | 32 + packages/shared/src/index.ts | 2 + packages/shared/src/plans.ts | 27 + packages/tunnel-server/src/db.ts | 5 +- packages/tunnel-server/src/ws-handler.ts | 10 +- pnpm-lock.yaml | 86 +- 25 files changed, 1416 insertions(+), 45 deletions(-) create mode 100644 apps/mobile/lib/billing.ts create mode 100644 apps/web/drizzle/0000_dodo_billing.sql create mode 100644 apps/web/drizzle/meta/0000_snapshot.json create mode 100644 apps/web/drizzle/meta/_journal.json create mode 100644 apps/web/src/app/api/billing/checkout/route.ts create mode 100644 apps/web/src/app/api/billing/confirm/route.ts create mode 100644 apps/web/src/app/api/billing/portal/route.ts create mode 100644 apps/web/src/app/api/billing/subscription/route.ts create mode 100644 apps/web/src/app/api/webhooks/dodo/route.ts create mode 100644 apps/web/src/app/billing/cancelled/page.tsx create mode 100644 apps/web/src/app/billing/success/page.tsx create mode 100644 apps/web/src/lib/billing-auth.ts create mode 100644 apps/web/src/lib/dodo.ts create mode 100644 packages/shared/src/plans.ts diff --git a/apps/mobile/app/(tabs)/user.tsx b/apps/mobile/app/(tabs)/user.tsx index 20134ae..2bc3a0c 100644 --- a/apps/mobile/app/(tabs)/user.tsx +++ b/apps/mobile/app/(tabs)/user.tsx @@ -12,6 +12,7 @@ import { useSettings } from "@/store/settings.store" import { useConnections } from "@/store/connection.store" import { useAuth } from "@/store/auth.store" import { getAccountNotificationSettings, registerPushDevice, updateAccountNotificationSettings } from "@/lib/account-notifications" +import { createBillingCheckout, createBillingPortal } from "@/lib/billing" import { requestNotificationsPermission } from "@/lib/notifications" import { TunnelUsageCard } from "@/components/TunnelUsageCard" import { OpencodeStatsCard } from "@/components/OpencodeStatsCard" @@ -117,6 +118,14 @@ export default function UserPage() { ) } + const openBilling = async (action: "checkout" | "portal", tier?: "starter" | "builder") => { + if (!serverUrl || !sessionToken) return + const url = action === "portal" + ? await createBillingPortal(serverUrl, sessionToken) + : await createBillingCheckout(serverUrl, sessionToken, tier || "starter") + if (url) await Linking.openURL(url) + } + const version = Constants.expoConfig?.version ?? "1.0.0" const buildNumber = Constants.expoConfig?.ios?.buildNumber ?? Constants.expoConfig?.android?.versionCode ?? undefined const versionDisplay = buildNumber ? `${version} (${buildNumber})` : version @@ -163,6 +172,26 @@ export default function UserPage() { + {isLoggedIn && user && ( + + + Subscription + Manage your CrossCode plan + + + Current tier: {user.tier} + {user.tier === "free" ? ( + + + + + ) : ( + + )} + + + )} + Appearance diff --git a/apps/mobile/lib/billing.ts b/apps/mobile/lib/billing.ts new file mode 100644 index 0000000..af97c8c --- /dev/null +++ b/apps/mobile/lib/billing.ts @@ -0,0 +1,25 @@ +export async function createBillingCheckout( + serverUrl: string, + sessionToken: string, + tier: "starter" | "builder", + cycle: "monthly" | "yearly" = "monthly", +): Promise { + const response = await fetch(`${serverUrl}/api/billing/checkout`, { + method: "POST", + headers: { Authorization: `Bearer ${sessionToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ tier, cycle }), + }) + if (!response.ok) return null + const data = await response.json() + return data.checkoutUrl ?? null +} + +export async function createBillingPortal(serverUrl: string, sessionToken: string): Promise { + const response = await fetch(`${serverUrl}/api/billing/portal`, { + method: "POST", + headers: { Authorization: `Bearer ${sessionToken}` }, + }) + if (!response.ok) return null + const data = await response.json() + return data.url ?? null +} diff --git a/apps/web/.env.example b/apps/web/.env.example index e98edf0..49144e9 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -20,3 +20,12 @@ NEXT_PUBLIC_APP_URL=https://crosscode.site NEXT_PUBLIC_SANITY_PROJECT_ID= NEXT_PUBLIC_SANITY_DATASET=production SANITY_API_READ_TOKEN= + +# DoDo Payments +DODO_PAYMENTS_API_KEY= +DODO_PAYMENTS_WEBHOOK_KEY= +DODO_PAYMENTS_ENVIRONMENT=live_mode +DODO_PRODUCT_STARTER_MONTHLY= +DODO_PRODUCT_STARTER_YEARLY= +DODO_PRODUCT_BUILDER_MONTHLY= +DODO_PRODUCT_BUILDER_YEARLY= diff --git a/apps/web/drizzle/0000_dodo_billing.sql b/apps/web/drizzle/0000_dodo_billing.sql new file mode 100644 index 0000000..d32128a --- /dev/null +++ b/apps/web/drizzle/0000_dodo_billing.sql @@ -0,0 +1,13 @@ +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "dodo_customer_id" text; +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "dodo_subscription_id" text; +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "subscription_status" text; +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "subscription_product_id" text; +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "subscription_renews_at" timestamp; +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "subscription_cancel_at_period_end" boolean DEFAULT false NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS "user_dodo_customer_id_unique" ON "user" ("dodo_customer_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "user_dodo_subscription_id_unique" ON "user" ("dodo_subscription_id"); +CREATE TABLE IF NOT EXISTS "dodo_webhook_event" ( + "id" text PRIMARY KEY NOT NULL, + "type" text NOT NULL, + "processed_at" timestamp DEFAULT now() NOT NULL +); diff --git a/apps/web/drizzle/meta/0000_snapshot.json b/apps/web/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..6b508b8 --- /dev/null +++ b/apps/web/drizzle/meta/0000_snapshot.json @@ -0,0 +1,737 @@ +{ + "id": "3c82dab2-0ae8-41ad-8ddd-0835f167d828", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_notification_settings": { + "name": "account_notification_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_response_completed": { + "name": "agent_response_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "agent_question_interruption": { + "name": "agent_question_interruption", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "agent_permission_interruption": { + "name": "agent_permission_interruption", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "agent_error_interruption": { + "name": "agent_error_interruption", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_notification_settings_user_id_user_id_fk": { + "name": "account_notification_settings_user_id_user_id_fk", + "tableFrom": "account_notification_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_session": { + "name": "device_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_session_user_id_user_id_fk": { + "name": "device_session_user_id_user_id_fk", + "tableFrom": "device_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_session_token_unique": { + "name": "device_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dodo_webhook_event": { + "name": "dodo_webhook_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_device": { + "name": "push_device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_session_id": { + "name": "device_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_device_user_id_user_id_fk": { + "name": "push_device_user_id_user_id_fk", + "tableFrom": "push_device", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "push_device_device_session_id_device_session_id_fk": { + "name": "push_device_device_session_id_device_session_id_fk", + "tableFrom": "push_device", + "tableTo": "device_session", + "columnsFrom": [ + "device_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_device_expo_push_token_unique": { + "name": "push_device_expo_push_token_unique", + "nullsNotDistinct": false, + "columns": [ + "expo_push_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dodo_customer_id": { + "name": "dodo_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dodo_subscription_id": { + "name": "dodo_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_product_id": { + "name": "subscription_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_renews_at": { + "name": "subscription_renews_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "subscription_cancel_at_period_end": { + "name": "subscription_cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_api_key_unique": { + "name": "user_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + }, + "user_dodo_customer_id_unique": { + "name": "user_dodo_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "dodo_customer_id" + ] + }, + "user_dodo_subscription_id_unique": { + "name": "user_dodo_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "dodo_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json new file mode 100644 index 0000000..9acfd07 --- /dev/null +++ b/apps/web/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1787071226446, + "tag": "0000_spicy_stephen_strange", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index b72a8f2..d2f3390 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "better-auth": "^1.6.25", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dodopayments": "^2.47.0", "drizzle-orm": "^0.45.2", "lucide-react": "^0.525.0", "next": "^16.2.12", diff --git a/apps/web/src/app/api/billing/checkout/route.ts b/apps/web/src/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..d75e3f5 --- /dev/null +++ b/apps/web/src/app/api/billing/checkout/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server" +import { getBillingUser } from "@/lib/billing-auth" +import { appUrl, getDodo, getProductId } from "@/lib/dodo" +import type { BillingCurrency, BillingCycle, PaidTier } from "@crosscode/shared" + +export async function POST(req: NextRequest) { + try { + const currentUser = await getBillingUser(req) + if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const body = await req.json() as { tier?: string; cycle?: string; currency?: string } + const tier = body.tier as PaidTier + const cycle = body.cycle as BillingCycle + const currency = body.currency as BillingCurrency | undefined + if (!["starter", "builder"].includes(tier) || !["monthly", "yearly"].includes(cycle)) { + return NextResponse.json({ error: "Invalid plan" }, { status: 400 }) + } + + const productId = getProductId(tier, cycle) + const checkout = await getDodo().checkoutSessions.create({ + product_cart: [{ product_id: productId, quantity: 1 }], + customer: currentUser.dodoCustomerId + ? { customer_id: currentUser.dodoCustomerId } + : { email: currentUser.email, name: currentUser.name }, + allowed_payment_method_types: ["upi_collect", "credit", "debit"], + ...(currency === "inr" ? { billing_currency: "INR" } : {}), + return_url: appUrl("/billing/success"), + cancel_url: appUrl("/billing/cancelled"), + metadata: { + user_id: currentUser.id, + tier, + cycle, + }, + feature_flags: { redirect_immediately: true }, + }) + + return NextResponse.json({ checkoutUrl: checkout.checkout_url, sessionId: checkout.session_id }) + } catch (error) { + console.error("Failed to create DoDo checkout session", error) + return NextResponse.json({ error: "Unable to start checkout" }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/billing/confirm/route.ts b/apps/web/src/app/api/billing/confirm/route.ts new file mode 100644 index 0000000..639e5ec --- /dev/null +++ b/apps/web/src/app/api/billing/confirm/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server" +import { eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { user } from "@/lib/db/schema" +import { getBillingUser } from "@/lib/billing-auth" +import { getDodo, tierFromProductId } from "@/lib/dodo" + +export async function POST(req: NextRequest) { + try { + const currentUser = await getBillingUser(req) + if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { subscriptionId } = await req.json() as { subscriptionId?: string } + if (!subscriptionId) return NextResponse.json({ error: "Missing subscription ID" }, { status: 400 }) + + const subscription = await getDodo().subscriptions.retrieve(subscriptionId) as unknown as Record + const customerId = String(subscription.customer_id || "") + if (customerId && currentUser.dodoCustomerId && customerId !== currentUser.dodoCustomerId) { + return NextResponse.json({ error: "Subscription does not belong to this account" }, { status: 403 }) + } + + const productId = String(subscription.product_id || "") + const tier = tierFromProductId(productId) + await db.update(user).set({ + dodoCustomerId: customerId || currentUser.dodoCustomerId, + dodoSubscriptionId: subscriptionId, + subscriptionProductId: productId || null, + subscriptionStatus: String(subscription.status || "active"), + subscriptionRenewsAt: subscription.next_billing_date ? new Date(String(subscription.next_billing_date)) : null, + subscriptionCancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end), + ...(tier ? { tier } : {}), + updatedAt: new Date(), + }).where(eq(user.id, currentUser.id)) + + return NextResponse.json({ tier: tier || currentUser.tier, status: subscription.status }) + } catch (error) { + console.error("Failed to confirm DoDo subscription", error) + return NextResponse.json({ error: "Unable to confirm subscription" }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/billing/portal/route.ts b/apps/web/src/app/api/billing/portal/route.ts new file mode 100644 index 0000000..dc2bdba --- /dev/null +++ b/apps/web/src/app/api/billing/portal/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server" +import { getBillingUser } from "@/lib/billing-auth" +import { getDodo } from "@/lib/dodo" + +export async function POST(req: NextRequest) { + try { + const currentUser = await getBillingUser(req) + if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + if (!currentUser.dodoCustomerId) return NextResponse.json({ error: "No billing account found" }, { status: 400 }) + + const portal = await getDodo().customers.customerPortal.create(currentUser.dodoCustomerId) + return NextResponse.json({ url: portal.link }) + } catch (error) { + console.error("Failed to create DoDo portal session", error) + return NextResponse.json({ error: "Unable to open billing portal" }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/billing/subscription/route.ts b/apps/web/src/app/api/billing/subscription/route.ts new file mode 100644 index 0000000..b580f4f --- /dev/null +++ b/apps/web/src/app/api/billing/subscription/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server" +import { getBillingUser } from "@/lib/billing-auth" + +export async function GET(req: NextRequest) { + const currentUser = await getBillingUser(req) + if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + return NextResponse.json({ + tier: currentUser.tier, + status: currentUser.subscriptionStatus, + renewsAt: currentUser.subscriptionRenewsAt, + cancelAtPeriodEnd: currentUser.subscriptionCancelAtPeriodEnd, + }) +} diff --git a/apps/web/src/app/api/webhooks/dodo/route.ts b/apps/web/src/app/api/webhooks/dodo/route.ts new file mode 100644 index 0000000..a0e0a9e --- /dev/null +++ b/apps/web/src/app/api/webhooks/dodo/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server" +import { eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { dodoWebhookEvent, user } from "@/lib/db/schema" +import { getDodo, tierFromProductId } from "@/lib/dodo" + +type WebhookPayload = { + type: string + data?: Record +} + +export async function POST(req: NextRequest) { + const rawBody = await req.text() + try { + const event = getDodo().webhooks.unwrap(rawBody, { + headers: { + "webhook-id": req.headers.get("webhook-id") || "", + "webhook-signature": req.headers.get("webhook-signature") || "", + "webhook-timestamp": req.headers.get("webhook-timestamp") || "", + }, + }) as unknown as WebhookPayload + const webhookId = req.headers.get("webhook-id") + if (!webhookId) return NextResponse.json({ error: "Missing webhook ID" }, { status: 400 }) + + try { + await db.insert(dodoWebhookEvent).values({ id: webhookId, type: event.type }) + } catch { + return NextResponse.json({ received: true }) + } + + const data = event.data || {} + const metadata = (data.metadata || {}) as Record + const userId = typeof metadata.user_id === "string" ? metadata.user_id : null + const customerId = typeof data.customer_id === "string" ? data.customer_id : null + const customer = data.customer as { email?: unknown } | undefined + const customerEmail = typeof customer?.email === "string" ? customer.email : null + const subscriptionId = typeof data.subscription_id === "string" ? data.subscription_id : null + const productId = typeof data.product_id === "string" ? data.product_id : null + const tier = productId ? tierFromProductId(productId) : null + + const currentUser = userId + ? await db.query.user.findFirst({ where: eq(user.id, userId) }) + : customerId + ? await db.query.user.findFirst({ where: eq(user.dodoCustomerId, customerId) }) + : customerEmail + ? await db.query.user.findFirst({ where: eq(user.email, customerEmail) }) + : null + if (!currentUser) return NextResponse.json({ received: true }) + + const status = event.type.startsWith("subscription.") + ? event.type.replace("subscription.", "") + : currentUser.subscriptionStatus + const ended = ["cancelled", "expired"].includes(status || "") + await db.update(user).set({ + dodoCustomerId: customerId || currentUser.dodoCustomerId, + dodoSubscriptionId: subscriptionId || currentUser.dodoSubscriptionId, + subscriptionProductId: productId || currentUser.subscriptionProductId, + subscriptionStatus: status, + subscriptionRenewsAt: data.next_billing_date ? new Date(String(data.next_billing_date)) : currentUser.subscriptionRenewsAt, + subscriptionCancelAtPeriodEnd: Boolean(data.cancel_at_period_end), + tier: ended ? "free" : tier || currentUser.tier, + updatedAt: new Date(), + }).where(eq(user.id, currentUser.id)) + + return NextResponse.json({ received: true }) + } catch (error) { + console.error("DoDo webhook verification or processing failed", error) + return NextResponse.json({ error: "Invalid webhook" }, { status: 400 }) + } +} diff --git a/apps/web/src/app/billing/cancelled/page.tsx b/apps/web/src/app/billing/cancelled/page.tsx new file mode 100644 index 0000000..8342a3b --- /dev/null +++ b/apps/web/src/app/billing/cancelled/page.tsx @@ -0,0 +1,17 @@ +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +export default function BillingCancelledPage() { + return ( +
+ + Checkout cancelled + +

No payment was made. You can return to pricing whenever you are ready.

+ +
+
+
+ ) +} diff --git a/apps/web/src/app/billing/success/page.tsx b/apps/web/src/app/billing/success/page.tsx new file mode 100644 index 0000000..1896e7a --- /dev/null +++ b/apps/web/src/app/billing/success/page.tsx @@ -0,0 +1,41 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +export default function BillingSuccessPage() { + const [message, setMessage] = useState( + "Your checkout is complete. Your account will update shortly." + ) + + useEffect(() => { + const nextSubscriptionId = new URLSearchParams(window.location.search).get("subscription_id") + if (!nextSubscriptionId) return + fetch("/api/billing/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subscriptionId: nextSubscriptionId }), + }).then((response) => { + setMessage(response.ok + ? "Your subscription is active. Welcome to CrossCode." + : "Your payment was received and your account will update shortly.") + }).catch(() => setMessage("Your payment was received and your account will update shortly.")) + }, []) + + return ( +
+ + Subscription update + +

{message}

+

+ Indian UPI and card mandates may take up to 48 hours to settle on recurring payments. Access remains synchronized from confirmed DoDo payment webhooks. +

+ +
+
+
+ ) +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index f2040b2..b2ca303 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -13,6 +13,7 @@ export default function DashboardPage() { const router = useRouter() const [apiKey, setApiKey] = useState(null) const [user, setUser] = useState<{ id: string; name: string; email: string; tier?: string } | null>(null) + const [subscription, setSubscription] = useState<{ status?: string; renewsAt?: string | null; cancelAtPeriodEnd?: boolean }>({}) const [loading, setLoading] = useState(true) const [copied, setCopied] = useState(false) const [qrDataUrl, setQrDataUrl] = useState(null) @@ -30,6 +31,8 @@ export default function DashboardPage() { return } setUser(data.user) + const billingResponse = await fetch("/api/billing/subscription") + if (billingResponse.ok) setSubscription(await billingResponse.json()) setLoading(false) } checkAuth() @@ -60,6 +63,22 @@ export default function DashboardPage() { router.push("/login") } + const openCheckout = async (tier: "starter" | "builder") => { + const response = await fetch("/api/billing/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tier, cycle: "monthly" }), + }) + const data = await response.json() + if (response.ok && data.checkoutUrl) window.location.assign(data.checkoutUrl) + } + + const openPortal = async () => { + const response = await fetch("/api/billing/portal", { method: "POST" }) + const data = await response.json() + if (response.ok && data.url) window.location.assign(data.url) + } + const generateLoginQR = async () => { setQrGenerating(true) try { @@ -187,6 +206,38 @@ export default function DashboardPage() {
+ + + Subscription + Manage your CrossCode plan and billing + + +
+ Status: + {subscription.status || (user?.tier === "free" ? "free" : "active")} +
+ {subscription.renewsAt && ( +
+ Renews: + {new Date(subscription.renewsAt).toLocaleDateString()} +
+ )} + {subscription.cancelAtPeriodEnd && ( +

Your subscription is scheduled to cancel at the end of this billing period.

+ )} +
+ {user?.tier === "free" ? ( + <> + + + + ) : ( + + )} +
+
+
+ Login with Phone diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index d153445..a7de7e6 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -54,7 +54,7 @@ export default function LoginPage() { if (error) { setError(error.message || "Invalid OTP") } else { - router.push("/dashboard") + router.push(new URLSearchParams(window.location.search).get("next") || "/dashboard") } } catch { setError("Failed to verify OTP") diff --git a/apps/web/src/app/pricing/page.tsx b/apps/web/src/app/pricing/page.tsx index e2ec7ff..e2f5fb4 100644 --- a/apps/web/src/app/pricing/page.tsx +++ b/apps/web/src/app/pricing/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Navbar } from "@/components/landing/navbar"; import { Footer } from "@/components/landing/footer"; import { Badge } from "@/components/ui/badge"; @@ -5,13 +7,31 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Check, X } from "lucide-react"; import Link from "next/link"; -import React from "react"; +import React, { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { useRouter } from "next/navigation"; +import { authClient } from "@/lib/auth-client"; +import { paidPlans, type BillingCurrency, type BillingCycle, type PaidTier } from "@crosscode/shared"; + +function detectCurrency(): BillingCurrency { + if (typeof window === "undefined") return "usd"; + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; + return timezone.includes("Kolkata") || timezone.includes("Calcutta") ? "inr" : "usd"; +} + +const subscribeCurrency = () => () => {}; + +function useDetectedCurrency(): BillingCurrency { + return useSyncExternalStore( + subscribeCurrency, + detectCurrency, + () => "usd" as BillingCurrency + ); +} const tiers = [ { name: "Free", - price: "$0", - period: "/mo", + tier: null, description: "For trying out CrossCode", features: [ { label: "Cloudflare (ephemeral) tunnel", included: true }, @@ -29,8 +49,7 @@ const tiers = [ }, { name: "Starter", - price: "$2", - period: "/mo", + tier: "starter" as PaidTier, description: "For individual developers", features: [ { label: "Custom VPS tunnel", included: true }, @@ -49,8 +68,7 @@ const tiers = [ }, { name: "Builder", - price: "$5", - period: "/mo", + tier: "builder" as PaidTier, description: "For power users and small teams", features: [ { label: "Custom VPS tunnel", included: true }, @@ -70,8 +88,7 @@ const tiers = [ }, { name: "Enterprise", - price: "Custom", - period: "", + tier: null, description: "For teams with tailored requirements", features: [ { label: "Custom tunnel limits", included: true }, @@ -88,6 +105,11 @@ const tiers = [ }, ]; +function discountPercent(tier: PaidTier, currency: "usd" | "inr") { + const plan = paidPlans[tier]; + return Math.round((1 - plan.yearly[currency] / (plan.monthly[currency] * 12)) * 100); +} + const comparisonFeatures = [ { category: "Tunnel", @@ -122,12 +144,48 @@ const comparisonFeatures = [ }, ]; -export const metadata = { - title: "Pricing - CrossCode", - description: "Simple, transparent pricing for CrossCode. Choose the plan that fits your needs.", -}; - export default function PricingPage() { + const router = useRouter(); + const [cycle, setCycle] = useState("monthly"); + const [currency, setCurrency] = useState(null); + const detectedCurrency = useDetectedCurrency(); + const effectiveCurrency = currency ?? detectedCurrency; + const [checkoutTier, setCheckoutTier] = useState(null); + + const startCheckout = useCallback(async (tier: PaidTier, selectedCycle: BillingCycle = cycle, selectedCurrency: BillingCurrency = effectiveCurrency) => { + setCheckoutTier(tier); + try { + const { data } = await authClient.getSession(); + if (!data?.session) { + router.push(`/login?next=${encodeURIComponent(`/pricing?plan=${tier}&cycle=${selectedCycle}¤cy=${selectedCurrency}`)}`); + return; + } + const response = await fetch("/api/billing/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tier, cycle: selectedCycle, currency: selectedCurrency }), + }); + const result = await response.json(); + if (!response.ok || !result.checkoutUrl) throw new Error(result.error || "Checkout failed"); + window.location.assign(result.checkoutUrl); + } catch (error) { + console.error("Unable to start checkout", error); + setCheckoutTier(null); + } + }, [cycle, effectiveCurrency, router]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const requestedTier = params.get("plan"); + const requestedCycle = params.get("cycle"); + const requestedCurrency = params.get("currency"); + if ((requestedTier === "starter" || requestedTier === "builder") && (requestedCycle === "monthly" || requestedCycle === "yearly")) { + authClient.getSession().then(({ data }) => { + if (data?.session) startCheckout(requestedTier, requestedCycle, requestedCurrency === "inr" ? "inr" : "usd"); + }); + } + }, [startCheckout]); + return (
@@ -142,7 +200,37 @@ export default function PricingPage() {

-
+
+
+ {(["monthly", "yearly"] as const).map((option) => ( + + ))} +
+
+ {(["usd", "inr"] as const).map((option) => ( + + ))} +
+
+ +
{tiers.map((tier) => (
- {tier.price} - {tier.period && ( - {tier.period} - )} + + {tier.tier + ? `${effectiveCurrency === "inr" ? "₹" : "$"}${paidPlans[tier.tier][cycle][effectiveCurrency]}` + : tier.name === "Free" + ? effectiveCurrency === "inr" ? "₹0" : "$0" + : "Custom"} + + {tier.name !== "Enterprise" && /{cycle === "monthly" ? "mo" : "yr"}}
+ {tier.tier && ( + <> +

+ {effectiveCurrency === "inr" ? `≈ $${paidPlans[tier.tier][cycle].usd}/yr` : `India: ₹${paidPlans[tier.tier][cycle].inr}/${cycle === "monthly" ? "mo" : "yr"}`} +

+ {cycle === "yearly" && ( +

+ Save {discountPercent(tier.tier, effectiveCurrency)}% versus monthly +

+ )} + + )}

{tier.description}

@@ -176,7 +280,7 @@ export default function PricingPage() { ) : ( )} - {feature.href ? ( + {"href" in feature && feature.href ? ( feature.label === "Unlimited fair-use traffic" ? ( Unlimited{" "} @@ -209,9 +313,11 @@ export default function PricingPage() {
diff --git a/apps/web/src/lib/billing-auth.ts b/apps/web/src/lib/billing-auth.ts new file mode 100644 index 0000000..18de21a --- /dev/null +++ b/apps/web/src/lib/billing-auth.ts @@ -0,0 +1,17 @@ +import { NextRequest } from "next/server" +import { auth } from "@/lib/auth" +import { getAccountDevice } from "@/lib/account-device-auth" +import { db } from "@/lib/db" +import { user } from "@/lib/db/schema" +import { eq } from "drizzle-orm" + +export async function getBillingUser(req: NextRequest) { + const session = await auth.api.getSession({ headers: req.headers }) + if (session) { + return await db.query.user.findFirst({ where: eq(user.id, session.user.id) }) + } + + const account = await getAccountDevice(req.headers.get("authorization")) + if ("error" in account) return null + return account.user +} diff --git a/apps/web/src/lib/db/schema.ts b/apps/web/src/lib/db/schema.ts index 95bd830..74991af 100644 --- a/apps/web/src/lib/db/schema.ts +++ b/apps/web/src/lib/db/schema.ts @@ -8,10 +8,22 @@ export const user = pgTable("user", { image: text("image"), tier: text("tier").notNull().default("free"), apiKey: text("api_key").unique(), + dodoCustomerId: text("dodo_customer_id").unique(), + dodoSubscriptionId: text("dodo_subscription_id").unique(), + subscriptionStatus: text("subscription_status"), + subscriptionProductId: text("subscription_product_id"), + subscriptionRenewsAt: timestamp("subscription_renews_at"), + subscriptionCancelAtPeriodEnd: boolean("subscription_cancel_at_period_end").notNull().default(false), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }) +export const dodoWebhookEvent = pgTable("dodo_webhook_event", { + id: text("id").primaryKey(), + type: text("type").notNull(), + processedAt: timestamp("processed_at").notNull().defaultNow(), +}) + export const session = pgTable("session", { id: text("id").primaryKey(), userId: text("user_id") diff --git a/apps/web/src/lib/dodo.ts b/apps/web/src/lib/dodo.ts new file mode 100644 index 0000000..e150928 --- /dev/null +++ b/apps/web/src/lib/dodo.ts @@ -0,0 +1,32 @@ +import DodoPayments from "dodopayments" + +const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === "test_mode" + ? "test_mode" + : "live_mode" + +export function getDodo() { + const bearerToken = process.env.DODO_PAYMENTS_API_KEY + if (!bearerToken) throw new Error("DODO_PAYMENTS_API_KEY is not configured") + return new DodoPayments({ + bearerToken, + environment, + webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY, + }) +} + +export function getProductId(tier: "starter" | "builder", cycle: "monthly" | "yearly") { + const key = `DODO_PRODUCT_${tier.toUpperCase()}_${cycle.toUpperCase()}` as const + const productId = process.env[key] + if (!productId) throw new Error(`${key} is not configured`) + return productId +} + +export function tierFromProductId(productId: string): "starter" | "builder" | null { + if (productId === process.env.DODO_PRODUCT_STARTER_MONTHLY || productId === process.env.DODO_PRODUCT_STARTER_YEARLY) return "starter" + if (productId === process.env.DODO_PRODUCT_BUILDER_MONTHLY || productId === process.env.DODO_PRODUCT_BUILDER_YEARLY) return "builder" + return null +} + +export function appUrl(path: string) { + return `${process.env.NEXT_PUBLIC_APP_URL || process.env.BETTER_AUTH_URL || "http://localhost:3000"}${path}` +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8cc7745..1894254 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -27,6 +27,8 @@ export type DeviceLinkQrPayload = { v: number } +export * from "./plans" + function toBase64(str: string): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' let result = '' diff --git a/packages/shared/src/plans.ts b/packages/shared/src/plans.ts new file mode 100644 index 0000000..ddcf82e --- /dev/null +++ b/packages/shared/src/plans.ts @@ -0,0 +1,27 @@ +export type BillingCycle = "monthly" | "yearly" +export type BillingCurrency = "usd" | "inr" +export type PaidTier = "starter" | "builder" + +export const paidPlans = { + starter: { + name: "Starter", + monthly: { usd: 2, inr: 175 }, + yearly: { usd: 20, inr: 1899 }, + }, + builder: { + name: "Builder", + monthly: { usd: 5, inr: 475 }, + yearly: { usd: 50, inr: 4799 }, + }, +} as const satisfies Record + +export const tierTunnelLimits: Record = { + free: 1, + starter: 1, + builder: 5, + enterprise: Number.POSITIVE_INFINITY, +} diff --git a/packages/tunnel-server/src/db.ts b/packages/tunnel-server/src/db.ts index 8375614..602cd6b 100644 --- a/packages/tunnel-server/src/db.ts +++ b/packages/tunnel-server/src/db.ts @@ -15,7 +15,10 @@ export async function validateApiKey(apiKey: string): Promise<{ userId: string; logger.debug("Validating API key", { apiKey: apiKey.substring(0, 8) + "..." }) try { const rows = await sql` - SELECT id, email, tier FROM "user" WHERE api_key = ${apiKey} LIMIT 1 + SELECT id, email, + CASE WHEN tier IN ('starter', 'builder', 'enterprise') AND COALESCE(subscription_status, '') != 'active' + THEN 'free' ELSE tier END AS tier + FROM "user" WHERE api_key = ${apiKey} LIMIT 1 ` if (rows.length === 0) { logger.warn("API key not found in database", { apiKey: apiKey.substring(0, 8) + "..." }) diff --git a/packages/tunnel-server/src/ws-handler.ts b/packages/tunnel-server/src/ws-handler.ts index 547d35d..2490996 100644 --- a/packages/tunnel-server/src/ws-handler.ts +++ b/packages/tunnel-server/src/ws-handler.ts @@ -2,6 +2,7 @@ import { WebSocket } from "ws" import { validateApiKey } from "./db.js" import { register, deregister, get, removePendingRequest, countByUserId } from "./registry.js" import type { TunnelC2S, TunnelS2C } from "@crosscode/shared" +import { tierTunnelLimits } from "@crosscode/shared" import crypto from "crypto" import { logger } from "./logger.js" @@ -138,10 +139,11 @@ export function handleWebSocket(ws: WebSocket, req: import("http").IncomingMessa return } - const activeTunnels = countByUserId(result.userId) - if (result.tier === "free" && activeTunnels >= 1) { - logger.warn("Auth failed: free tier tunnel limit reached", { projectId: projId, userId: result.userId, activeTunnels }) - send(ws, { type: "auth.fail", reason: "Free plan allows only 1 active custom tunnel. Upgrade for more." }) + const activeTunnels = countByUserId(result.userId) + const tunnelLimit = tierTunnelLimits[result.tier] ?? tierTunnelLimits.free + if (activeTunnels >= tunnelLimit) { + logger.warn("Auth failed: tunnel limit reached", { projectId: projId, userId: result.userId, tier: result.tier, activeTunnels, tunnelLimit }) + send(ws, { type: "auth.fail", reason: `${result.tier} plan allows ${tunnelLimit} active tunnel${tunnelLimit === 1 ? "" : "s"}. Upgrade for more.` }) ws.close(4003, "Tunnel limit reached") return } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a1bd48..1aa3d8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,12 @@ importers: nativewind: specifier: ^4.2.6 version: 4.2.6(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(tailwindcss@3.4.17) + prism-react-renderer: + specifier: ^2.4.1 + version: 2.4.1(react@19.2.3) + prismjs: + specifier: ^1.29.0 + version: 1.30.0 react: specifier: 19.2.3 version: 19.2.3 @@ -206,7 +212,7 @@ importers: version: 2.1.1 '@sanity/vision': specifier: ^6.9.2 - version: 6.9.2(kpxfdzo2ri5s5fzcvhi2vl5hwq) + version: 6.9.2(qyrdcirsfetoszvdfcpvmthi6i) '@tailwindcss/postcss': specifier: ^4.1.11 version: 4.3.3 @@ -225,6 +231,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dodopayments: + specifier: ^2.47.0 + version: 2.47.0 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(kysely@0.29.4)(postgres@3.4.9) @@ -236,7 +245,7 @@ importers: version: 16.2.12(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-sanity: specifier: ^13.3.3 - version: 13.3.3(zr5qijm5tnrieupv4r2xxrsnhu) + version: 13.3.3(dixuumzgngq34ckzxgjqk57cde) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -263,7 +272,7 @@ importers: version: 4.0.1 sanity: specifier: ^6.9.2 - version: 6.9.2(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)))(@sanity/sdk@2.19.0(@types/react@19.2.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(xstate@5.32.5))(@types/node@22.20.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(styled-components@6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(terser@5.48.0)(typescript@5.9.3) + version: 6.9.2(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)))(@sanity/sdk@2.19.0(@types/react@19.2.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(xstate@5.32.5))(@types/node@22.20.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(jiti@2.7.0)(prismjs@1.30.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(styled-components@6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(terser@5.48.0)(typescript@5.9.3) styled-components: specifier: ^6.5.3 version: 6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) @@ -5084,6 +5093,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -6564,6 +6576,10 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dodopayments@2.47.0: + resolution: {integrity: sha512-4lmI7/NDJjJOmy2jGJaowt3/heXx01IDwPkiuuHqifcdi4+nFg1d1vrlcE8ULw4r+TO+TA1ZPFyaHxSm8hbjvQ==} + hasBin: true + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -7314,6 +7330,9 @@ packages: fast-levenshtein@3.0.0: resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -9441,6 +9460,15 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + prism-react-renderer@2.4.1: + resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} + peerDependencies: + react: '>=16.0.0' + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + proc-log@4.2.0: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -10195,6 +10223,9 @@ packages: standard-navigation@0.0.7: resolution: {integrity: sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -15393,9 +15424,7 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.74.89': optional: true @@ -16215,7 +16244,9 @@ snapshots: '@sanity/client': 7.26.2 '@sanity/uuid': 3.0.3 - '@sanity/prism-groq@1.1.2': {} + '@sanity/prism-groq@1.1.2(prismjs@1.30.0)': + optionalDependencies: + prismjs: 1.30.0 '@sanity/runtime-cli@17.7.0(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@types/node@22.20.1)(@types/react@19.2.17)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(rolldown@1.2.4)(terser@5.48.0)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0)': dependencies: @@ -16378,7 +16409,7 @@ snapshots: dependencies: uuid: 11.1.1 - '@sanity/vision@6.9.2(kpxfdzo2ri5s5fzcvhi2vl5hwq)': + '@sanity/vision@6.9.2(qyrdcirsfetoszvdfcpvmthi6i)': dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/commands': 6.11.0 @@ -16406,7 +16437,7 @@ snapshots: react: 19.2.3 react-rx: 5.1.1(react@19.2.3)(rxjs@7.8.2) rxjs: 7.8.2 - sanity: 6.9.2(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)))(@sanity/sdk@2.19.0(@types/react@19.2.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(xstate@5.32.5))(@types/node@22.20.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(styled-components@6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(terser@5.48.0)(typescript@5.9.3) + sanity: 6.9.2(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)))(@sanity/sdk@2.19.0(@types/react@19.2.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(xstate@5.32.5))(@types/node@22.20.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(jiti@2.7.0)(prismjs@1.30.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(styled-components@6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(terser@5.48.0)(typescript@5.9.3) use-effect-event: 2.0.3(react@19.2.3) transitivePeerDependencies: - '@babel/runtime' @@ -16598,6 +16629,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -18054,6 +18087,10 @@ snapshots: dependencies: esutils: 2.0.3 + dodopayments@2.47.0: + dependencies: + standardwebhooks: 1.0.0 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -18403,7 +18440,7 @@ snapshots: '@next/eslint-plugin-next': 16.2.12 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) @@ -18426,7 +18463,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@8.1.1) @@ -18441,14 +18478,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -18463,7 +18500,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -19035,6 +19072,8 @@ snapshots: dependencies: fastest-levenshtein: 1.0.16 + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -20983,7 +21022,7 @@ snapshots: negotiator@1.0.0: {} - next-sanity@13.3.3(zr5qijm5tnrieupv4r2xxrsnhu): + next-sanity@13.3.3(dixuumzgngq34ckzxgjqk57cde): dependencies: '@portabletext/react': 7.0.1(react@19.2.3) '@sanity/client': 7.26.2 @@ -20996,7 +21035,7 @@ snapshots: next: 16.2.12(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - sanity: 6.9.2(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)))(@sanity/sdk@2.19.0(@types/react@19.2.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(xstate@5.32.5))(@types/node@22.20.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(styled-components@6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(terser@5.48.0)(typescript@5.9.3) + sanity: 6.9.2(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)))(@sanity/sdk@2.19.0(@types/react@19.2.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(xstate@5.32.5))(@types/node@22.20.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(jiti@2.7.0)(prismjs@1.30.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(styled-components@6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(terser@5.48.0)(typescript@5.9.3) styled-components: 6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - '@sveltejs/kit' @@ -21452,6 +21491,14 @@ snapshots: dependencies: parse-ms: 4.0.0 + prism-react-renderer@2.4.1(react@19.2.3): + dependencies: + '@types/prismjs': 1.26.6 + clsx: 2.1.1 + react: 19.2.3 + + prismjs@1.30.0: {} + proc-log@4.2.0: {} process-nextick-args@2.0.1: {} @@ -22247,7 +22294,7 @@ snapshots: safer-buffer@2.1.2: {} - sanity@6.9.2(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)))(@sanity/sdk@2.19.0(@types/react@19.2.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(xstate@5.32.5))(@types/node@22.20.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(styled-components@6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(terser@5.48.0)(typescript@5.9.3): + sanity@6.9.2(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@noble/hashes@2.3.0)(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)))(@sanity/sdk@2.19.0(@types/react@19.2.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(xstate@5.32.5))(@types/node@22.20.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(jiti@2.7.0)(prismjs@1.30.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(styled-components@6.5.3(react-dom@19.2.3(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(terser@5.48.0)(typescript@5.9.3): dependencies: '@algorithm.ts/lcs': 4.0.6 '@date-fns/tz': 1.5.0 @@ -22295,7 +22342,7 @@ snapshots: '@sanity/mutator': 6.9.2(@types/react@19.2.17) '@sanity/presentation-comlink': 2.2.3(@sanity/types@6.9.2(@types/react@19.2.17)) '@sanity/preview-url-secret': 4.1.4(@sanity/client@7.26.2) - '@sanity/prism-groq': 1.1.2 + '@sanity/prism-groq': 1.1.2(prismjs@1.30.0) '@sanity/schema': 6.9.2(@types/react@19.2.17) '@sanity/telemetry': 1.1.0(react@19.2.3) '@sanity/types': 6.9.2(@types/react@19.2.17) @@ -22626,6 +22673,11 @@ snapshots: standard-navigation@0.0.7: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@1.5.0: {} statuses@2.0.2: {} From 96872c2ad92e124b879b80dfe0296a186596d4c7 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Wed, 19 Aug 2026 00:13:34 +0530 Subject: [PATCH 2/2] chore: regenerate lockfile without uncommitted mobile prism deps --- pnpm-lock.yaml | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1aa3d8b..9c23031 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,12 +128,6 @@ importers: nativewind: specifier: ^4.2.6 version: 4.2.6(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(tailwindcss@3.4.17) - prism-react-renderer: - specifier: ^2.4.1 - version: 2.4.1(react@19.2.3) - prismjs: - specifier: ^1.29.0 - version: 1.30.0 react: specifier: 19.2.3 version: 19.2.3 @@ -9460,11 +9454,6 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - prism-react-renderer@2.4.1: - resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} - peerDependencies: - react: '>=16.0.0' - prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -21491,13 +21480,8 @@ snapshots: dependencies: parse-ms: 4.0.0 - prism-react-renderer@2.4.1(react@19.2.3): - dependencies: - '@types/prismjs': 1.26.6 - clsx: 2.1.1 - react: 19.2.3 - - prismjs@1.30.0: {} + prismjs@1.30.0: + optional: true proc-log@4.2.0: {}