Conversation
Co-authored-by: otdoges <otdoges@proton.me>
|
You have run out of free Bugbot PR reviews for this billing cycle. This will reset on December 17. To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎
Codebase SummaryZapDev is an AI-powered development platform built with Next.js, React, and Convex that allows users to generate and manage web applications through an interactive UI. The application integrates multiple services including authentication (now using Stack Auth), subscription management via Polar.sh, real-time code generation, and Convex for backend data querying and mutations. PR ChangesThis PR migrates user authentication from Better Auth to Stack Auth, updating how user IDs are stored in the schema and Convex integrations. It also integrates Polar.sh for handling subscription checkouts: new API routes are added for creating checkout sessions, handling Polar webhooks are set up, and the Polar client is configured. UI components such as the Polar checkout button now show proper loading states and error messages via toast notifications. Setup Instructions
Generated Test Cases1: Display Unauthorized Error on Polar Checkout When Not Logged In ❗️❗️❗️Description: Verifies that if a user who is not logged in attempts to initiate a Polar checkout session, the UI shows a proper unauthorized error message. Prerequisites:
Steps:
Expected Result: A toast notification appears with an error message such as 'Unauthorized' or 'Please sign in to continue' preventing the checkout process. 2: Successful Polar Checkout Redirection for Logged In User ❗️❗️❗️Description: Tests that a logged-in user with proper Polar configuration can successfully create a checkout session and is redirected to the provided Polar checkout URL. Prerequisites:
Steps:
Expected Result: After clicking the checkout button, the user is redirected to a URL provided by Polar (e.g., a URL starting with https://checkout.polar.sh or similar), confirming successful session creation. 3: Display Error When Polar Payment System Is Not Configured ❗️❗️❗️Description: Ensures that the UI correctly handles and displays an error when the Polar payment system is misconfigured or environment variables are missing. Prerequisites:
Steps:
Expected Result: The system shows a toast error notification with a message like 'Payment system is not configured' along with additional details advising the user to contact support. 4: Show Loading Indicator on Polar Checkout Button During API Call ❗️❗️Description: Verifies that when the Polar checkout process is initiated, the checkout button reflects a loading state until the API response is received. Prerequisites:
Steps:
Expected Result: Upon clicking, the checkout button displays a loading indicator (spinner or disabled state). Once the API call completes, the loading state is removed and the user is redirected (on success) or shown an error (on failure). 5: Verify Convex Authentication Integration with Stack Auth ❗️❗️Description: Checks that the application initializes Convex authentication using Stack Auth (with token stored in nextjs-cookie) and that protected routes/pages load successfully for logged-in users. Prerequisites:
Steps:
Expected Result: Protected pages load without authentication errors, and the console or network logs show that Convex is using the Stack Auth token (via nextjs-cookie) for API requests. Raw Changes AnalyzedFile: convex/helpers.ts
Changes:
@@ -65,7 +65,7 @@ export async function hasProAccess(
}
/**
- * Legacy compatibility: Get user ID (now just returns Better Auth user ID)
+ * Legacy compatibility: Get user ID (alias for Stack Auth identifiers)
* @deprecated Use getCurrentUserId instead
*/
export async function getCurrentUserClerkId(
File: convex/schema.ts
Changes:
@@ -67,7 +67,7 @@ export default defineSchema({
// Projects table
projects: defineTable({
name: v.string(),
- userId: v.string(), // Better Auth user ID (not v.id - we'll store the Better Auth ID directly)
+ userId: v.string(), // Stack Auth user ID (not v.id - we store the Stack Auth ID directly)
framework: frameworkEnum,
modelPreference: v.optional(v.string()), // User's preferred AI model (e.g., "auto", "anthropic/claude-haiku-4.5", "openai/gpt-4o")
createdAt: v.optional(v.number()), // timestamp
@@ -135,7 +135,7 @@ export default defineSchema({
// OAuth Connections table - for storing encrypted OAuth tokens
oauthConnections: defineTable({
- userId: v.string(), // Better Auth user ID
+ userId: v.string(), // Stack Auth user ID
provider: oauthProviderEnum,
accessToken: v.string(), // Encrypted token
refreshToken: v.optional(v.string()),
@@ -150,7 +150,7 @@ export default defineSchema({
// Imports table - tracking import history and status
imports: defineTable({
- userId: v.string(), // Better Auth user ID
+ userId: v.string(), // Stack Auth user ID
projectId: v.id("projects"),
messageId: v.optional(v.id("messages")),
source: importSourceEnum,
@@ -169,7 +169,7 @@ export default defineSchema({
// Usage table - rate limiting and credit tracking
usage: defineTable({
- userId: v.string(), // Better Auth user ID
+ userId: v.string(), // Stack Auth user ID
points: v.number(), // Remaining credits
expire: v.optional(v.number()), // Expiration timestamp
planType: v.optional(v.union(v.literal("free"), v.literal("pro"))), // Track plan type
@@ -190,7 +190,7 @@ export default defineSchema({
// Subscriptions table - Polar.sh subscription tracking
subscriptions: defineTable({
- userId: v.string(), // Better Auth user ID
+ userId: v.string(), // Stack Auth user ID
polarCustomerId: v.string(), // Polar.sh customer ID
polarSubscriptionId: v.string(), // Polar.sh subscription ID
productId: v.string(), // Polar product ID
@@ -218,7 +218,7 @@ export default defineSchema({
sandboxSessions: defineTable({
sandboxId: v.string(), // E2B sandbox ID
projectId: v.id("projects"), // Associated project
- userId: v.string(), // Better Auth user ID
+ userId: v.string(), // Stack Auth user ID
framework: frameworkEnum, // Framework for the sandbox
state: sandboxStateEnum, // RUNNING, PAUSED, or KILLED
lastActivity: v.number(), // Timestamp of last user activity
@@ -268,7 +268,7 @@ export default defineSchema({
jobQueue: defineTable({
type: v.string(), // Job type: "code_generation", "error_fix", etc.
projectId: v.id("projects"),
- userId: v.string(), // Better Auth user ID
+ userId: v.string(), // Stack Auth user ID
payload: v.any(), // Job-specific data (event.data from Inngest)
priority: v.union(v.literal("high"), v.literal("normal"), v.literal("low")),
status: v.union(
File: src/app/.well-known/openid-configuration/route.ts
Changes:
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server";
export async function GET() {
- const baseUrl = process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000";
+ const baseUrl =
+ process.env.NEXT_PUBLIC_APP_URL ||
+ process.env.NEXT_PUBLIC_BASE_URL ||
+ "http://localhost:3000";
return NextResponse.json({
issuer: baseUrl,
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
File: src/app/api/polar/create-checkout/route.ts
Changes:
@@ -1,34 +1,131 @@
import { NextRequest, NextResponse } from "next/server";
+
import { getUser } from "@/lib/stack-auth";
+import {
+ getPolarClient,
+ getPolarOrganizationId,
+ getPolarProProductId,
+ isPolarConfigured,
+} from "@/lib/polar-client";
+import { getSanitizedErrorDetails, validatePolarEnv } from "@/lib/env-validation";
+
+type CheckoutRequest = {
+ productId?: string;
+ successUrl?: string;
+ cancelUrl?: string;
+};
+
+function getBaseUrl(): string {
+ return (
+ process.env.NEXT_PUBLIC_APP_URL ||
+ process.env.NEXT_PUBLIC_BASE_URL ||
+ "http://localhost:3000"
+ );
+}
+
+function buildResponse(
+ status: number,
+ payload: {
+ error: string;
+ details?: string;
+ isConfigError?: boolean;
+ adminMessage?: string;
+ },
+) {
+ return NextResponse.json(payload, { status });
+}
-// NOTE: Polar checkout will be implemented after Stack Auth is fully configured
-// This is a placeholder route for now
export async function POST(req: NextRequest) {
try {
- // Authenticate user with Stack Auth
const user = await getUser();
-
if (!user) {
- return NextResponse.json(
- { error: "Unauthorized - Please sign in to continue" },
- { status: 401 }
- );
+ return buildResponse(401, {
+ error: "Unauthorized",
+ details: "Please sign in to continue",
+ });
}
- // TODO: Implement Polar checkout once Stack Auth is configured with proper API keys
- return NextResponse.json(
- { error: "Polar checkout not yet configured. Please set up Stack Auth first." },
- { status: 501 }
- );
+ if (!isPolarConfigured()) {
+ return buildResponse(503, {
+ error: "Payment system is not configured",
+ details: "Please contact support while we finish setting up billing.",
+ isConfigError: true,
+ adminMessage: "Missing Polar environment variables. Run validatePolarEnv() for details.",
+ });
+ }
+
+ const body = (await req.json().catch(() => ({}))) as CheckoutRequest;
+ const requestedProductId = body.productId?.trim();
+
+ let productId = requestedProductId ?? "";
+ if (!productId) {
+ try {
+ productId = getPolarProProductId();
+ } catch {
+ return buildResponse(503, {
+ error: "Polar product is not configured",
+ details: "Set NEXT_PUBLIC_POLAR_PRO_PRODUCT_ID to your Polar product ID.",
+ isConfigError: true,
+ adminMessage: "NEXT_PUBLIC_POLAR_PRO_PRODUCT_ID is missing",
+ });
+ }
+ }
+ if (!productId) {
+ return buildResponse(500, {
+ error: "Unable to determine Polar product",
+ details: "Product ID resolution failed unexpectedly.",
+ adminMessage: "Polar product ID empty after configuration check",
+ });
+ }
+
+ validatePolarEnv(true);
+ const polar = getPolarClient();
+
+ const baseUrl = getBaseUrl();
+ const successUrl =
+ body.successUrl || `${baseUrl}/dashboard/subscription?status=success`;
+ const cancelUrl =
+ body.cancelUrl || `${baseUrl}/dashboard/subscription?status=cancelled`;
+
+ const checkout = await polar.checkoutSessions.create({
+ organizationId: getPolarOrganizationId(),
+ productPriceId: productId,
+ successUrl,
+ cancelUrl,
+ customerEmail: user.primaryEmail ?? undefined,
+ customerName: user.name ?? undefined,
+ metadata: {
+ userId: user.id,
+ userEmail: user.primaryEmail ?? undefined,
+ },
+ });
+
+ if (!checkout?.url) {
+ throw new Error("Polar checkout session did not include a redirect URL");
+ }
+
+ return NextResponse.json({
+ checkoutId: checkout.id,
+ url: checkout.url,
+ });
} catch (error) {
+ const details = getSanitizedErrorDetails(error);
+ const adminMessage =
+ error instanceof Error ? error.message : "Unknown Polar checkout error";
+
console.error("Error creating Polar checkout session:", error);
-
- return NextResponse.json(
- {
- error: "Failed to create checkout session",
- details: error instanceof Error ? error.message : "Unknown error"
- },
- { status: 500 }
- );
+
+ const isAuthError =
+ typeof details === "string" &&
+ (details.includes("Authentication failed") ||
+ details.includes("invalid or expired"));
+
+ const status = isAuthError ? 401 : 500;
+
+ return buildResponse(status, {
+ error: "Unable to start checkout",
+ details,
+ adminMessage,
+ });
}
}
File: src/app/api/webhooks/polar/route.ts
Changes:
@@ -0,0 +1,221 @@
+import { NextRequest, NextResponse } from "next/server";
+import { validateEvent } from "@polar-sh/sdk/webhooks";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+
+import { api } from "@/convex/_generated/api";
+import { getPolarWebhookSecret } from "@/lib/polar-client";
+import {
+ buildSubscriptionIdempotencyKey,
+ extractUserIdFromMetadata,
+ sanitizeSubscriptionMetadata,
+} from "@/lib/subscription-metadata";
+
+type PolarSubscriptionPayload = {
+ id?: string;
+ status?: string;
+ customerId?: string;
+ customer?: { id?: string; metadata?: unknown };
+ productId?: string;
+ product?: { id?: string; name?: string };
+ productName?: string;
+ metadata?: unknown;
+ currentPeriodStart?: string | number;
+ current_period_start?: string | number;
+ currentPeriodEnd?: string | number;
+ current_period_end?: string | number;
+ cancelAtPeriodEnd?: boolean;
+ cancel_at_period_end?: boolean;
+ updatedAt?: string | number;
+ updated_at?: string | number;
+};
+
+type SubscriptionStatus =
+ | "incomplete"
+ | "active"
+ | "canceled"
+ | "past_due"
+ | "unpaid";
+
+function toObject(value: unknown): Record<string, unknown> {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return {};
+ }
+ return value as Record<string, unknown>;
+}
+
+function toTimestamp(value: unknown): number {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (value instanceof Date) {
+ return value.getTime();
+ }
+ const parsed = Date.parse(String(value));
+ return Number.isNaN(parsed) ? Date.now() : parsed;
+}
+
+function normalizeStatus(status: unknown): SubscriptionStatus {
+ switch (String(status ?? "").toLowerCase()) {
+ case "active":
+ return "active";
+ case "canceled":
+ return "canceled";
+ case "past_due":
+ return "past_due";
+ case "unpaid":
+ return "unpaid";
+ default:
+ return "incomplete";
+ }
+}
+
+async function upsertSubscription(
+ data: PolarSubscriptionPayload,
+ userId: string,
+) {
+ const metadata = sanitizeSubscriptionMetadata(
+ data.metadata ?? data.customer?.metadata ?? {},
+ );
+
+ await fetchMutation(api.subscriptions.createOrUpdateSubscription, {
+ userId,
+ polarCustomerId: data.customerId || data.customer?.id || "",
+ polarSubscriptionId: data.id || "",
+ productId: data.productId || data.product?.id || "",
+ productName: data.productName || data.product?.name || "Pro",
+ status: normalizeStatus(data.status),
+ currentPeriodStart: toTimestamp(
+ data.currentPeriodStart ?? data.current_period_start ?? Date.now(),
+ ),
+ currentPeriodEnd: toTimestamp(
+ data.currentPeriodEnd ?? data.current_period_end ?? Date.now(),
+ ),
+ cancelAtPeriodEnd:
+ data.cancelAtPeriodEnd ?? data.cancel_at_period_end ?? false,
+ metadata,
+ });
+}
+
+async function markCancellation(polarSubscriptionId: string) {
+ await fetchMutation(api.subscriptions.markSubscriptionForCancellation, {
+ polarSubscriptionId,
+ });
+}
+
+async function revokeSubscription(polarSubscriptionId: string) {
+ await fetchMutation(api.subscriptions.revokeSubscription, {
+ polarSubscriptionId,
+ });
+}
+
+async function reactivateSubscription(polarSubscriptionId: string) {
+ await fetchMutation(api.subscriptions.reactivateSubscription, {
+ polarSubscriptionId,
+ });
+}
+
+export async function POST(request: NextRequest) {
+ const signature = request.headers.get("webhook-signature");
+ if (!signature) {
+ return NextResponse.json(
+ { error: "Missing webhook signature" },
+ { status: 400 },
+ );
+ }
+
+ const rawBody = await request.text();
+
+ let parsedEvent: Record<string, unknown>;
+ try {
+ const verified = validateEvent(rawBody, signature, getPolarWebhookSecret());
+ parsedEvent = toObject(
+ typeof verified === "string" ? JSON.parse(verified) : verified,
+ );
+ } catch (error) {
+ console.error("Polar webhook signature verification failed:", error);
+ return NextResponse.json(
+ { error: "Invalid webhook signature" },
+ { status: 400 },
+ );
+ }
+
+ const eventType = String(parsedEvent.type ?? parsedEvent.event ?? "").trim();
+ const data = toObject(parsedEvent.data);
+ const subscription = data as PolarSubscriptionPayload;
+
+ const subscriptionId = subscription.id;
+ if (!subscriptionId) {
+ return NextResponse.json(
+ { error: "Subscription ID missing from webhook payload" },
+ { status: 400 },
+ );
+ }
+
+ const updatedAt =
+ subscription.updatedAt ?? subscription.updated_at ?? Date.now();
+
+ const idempotencyKey = buildSubscriptionIdempotencyKey({
+ id: subscriptionId,
+ updatedAt,
+ status: subscription.status || "",
+ });
+
+ const duplicate = await fetchQuery(api.webhookEvents.isDuplicate, {
+ idempotencyKey,
+ });
+ if (duplicate) {
+ return NextResponse.json({ success: true, duplicate: true });
+ }
+
+ await fetchMutation(api.webhookEvents.recordProcessedEvent, {
+ idempotencyKey,
+ provider: "polar",
+ eventType: eventType || "unknown",
+ });
+
+ const metadataResult = extractUserIdFromMetadata(
+ subscription.metadata ?? subscription.customer?.metadata ?? {},
+ );
+ const userId = metadataResult.userId;
+
+ if (!userId) {
+ console.warn(
+ `[Polar webhook] Missing userId in metadata for subscription ${subscriptionId}`,
+ );
+ return NextResponse.json(
+ { error: "User metadata missing in subscription payload" },
+ { status: 422 },
+ );
+ }
+
+ try {
+ switch (eventType) {
+ case "subscription.created":
+ case "subscription.active":
+ case "subscription.updated":
+ await upsertSubscription(subscription, userId);
+ break;
+ case "subscription.uncanceled":
+ await upsertSubscription(subscription, userId);
+ await reactivateSubscription(subscriptionId);
+ break;
+ case "subscription.canceled":
+ await markCancellation(subscriptionId);
+ break;
+ case "subscription.revoked":
+ await revokeSubscription(subscriptionId);
+ break;
+ default:
+ // Unhandled events are acknowledged for idempotency
+ break;
+ }
+ } catch (error) {
+ console.error("Error processing Polar webhook:", error);
+ return NextResponse.json(
+ { error: "Failed to sync subscription data" },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json({ success: true });
+}
File: src/components/convex-provider.tsx
Changes:
@@ -12,7 +12,11 @@ export function ConvexClientProvider({ children }: { children: ReactNode }) {
useEffect(() => {
// Set Stack Auth authentication for Convex
- convex.setAuth(stackApp.getConvexClientAuth({}));
+ convex.setAuth(
+ stackApp.getConvexClientAuth({
+ tokenStore: "nextjs-cookie",
+ })
+ );
}, [stackApp]);
return (
File: src/components/polar-checkout-button.tsx
Changes:
@@ -47,36 +47,54 @@ export function PolarCheckoutButton({
return;
}
- // Call our API to create a Polar checkout session
- const response = await fetch("/api/polar/create-checkout", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- productId,
- userId: user.id,
- }),
- });
+ // Call our API to create a Polar checkout session
+ const response = await fetch("/api/polar/create-checkout", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ productId,
+ userId: user.id,
+ }),
+ });
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || "Failed to create checkout session");
- }
+ const payload = await response.json().catch(() => ({}));
- const { url } = await response.json();
-
- if (url) {
- window.location.href = url;
- }
+ if (!response.ok) {
+ const errorMessage =
+ typeof payload?.error === "string"
+ ? payload.error
+ : "Failed to create checkout session";
+ const description =
+ typeof payload?.details === "string"
+ ? payload.details
+ : "Please try again later.";
+
+ toast.error(errorMessage, { description });
+
+ if (typeof payload?.adminMessage === "string") {
+ console.error("🔧 Polar checkout admin message:", payload.adminMessage);
+ }
+ return;
+ }
- } catch (error) {
- console.error("Checkout error:", error);
- toast.error("Unable to start checkout", {
- description: error instanceof Error ? error.message : "Please try again later.",
- });
- setIsLoading(false);
- }
+ if (payload?.url) {
+ window.location.href = payload.url as string;
+ return;
+ }
+
+ toast.error("Unable to start checkout", {
+ description: "Polar did not return a checkout URL. Please try again.",
+ });
+ } catch (error) {
+ console.error("Checkout error:", error);
+ toast.error("Unable to start checkout", {
+ description: error instanceof Error ? error.message : "Please try again later.",
+ });
+ } finally {
+ setIsLoading(false);
+ }
};
return (
File: src/lib/convex-auth.ts
Changes:
@@ -1,4 +1,4 @@
-import { exportJWK, generateKeyPair, importPKCS8, importSPKI, SignJWT } from 'jose';
+import { exportJWK, generateKeyPair, importPKCS8, importSPKI, JWTPayload, SignJWT } from 'jose';
type StoredKey = {
kid: string;
@@ -213,17 +213,25 @@ export async function getJWKS() {
return jwks;
}
+function getAppIssuer(): string {
+ return (
+ process.env.NEXT_PUBLIC_APP_URL ||
+ process.env.NEXT_PUBLIC_BASE_URL ||
+ "http://localhost:3000"
+ );
+}
+
/**
* Signs a JWT for Convex authentication
* @param payload - The payload to sign
* @returns The signed JWT string
*/
-export async function signConvexJWT(payload: any) {
+export async function signConvexJWT(payload: JWTPayload) {
const { privateKey, kid } = await getKeys();
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: ALG, kid })
.setIssuedAt()
- .setIssuer(process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000")
+ .setIssuer(getAppIssuer())
.setAudience("convex")
.setExpirationTime('1h')
.sign(privateKey);
File: src/lib/polar-client.ts
Changes:
@@ -0,0 +1,95 @@
+import { Polar } from "@polar-sh/sdk";
+import { hasEnvVar, validatePolarEnv } from "./env-validation";
+
+const PLACEHOLDER_TOKEN = "polar-placeholder-token";
+
+let polarClientInstance: Polar | null = null;
+
+function isBuildPhase(): boolean {
+ return process.env.NEXT_PHASE === "phase-production-build";
+}
+
+function resolveServer(): "sandbox" | "production" {
+ const configured = process.env.POLAR_SERVER?.toLowerCase();
+
+ if (configured === "production") {
+ return "production";
+ }
+
+ if (configured === "sandbox") {
+ return "sandbox";
+ }
+
+ // Default to sandbox unless explicitly in production mode
+ return process.env.NODE_ENV === "production" ? "production" : "sandbox";
+}
+
+function createPolarClient(): Polar {
+ const buildPhase = isBuildPhase();
+ validatePolarEnv(!buildPhase);
+
+ const accessToken = process.env.POLAR_ACCESS_TOKEN?.trim();
+
+ if (!accessToken) {
+ if (buildPhase) {
+ console.warn("⚠️ POLAR_ACCESS_TOKEN not configured. Using placeholder client for build.");
+ return new Polar({
+ accessToken: PLACEHOLDER_TOKEN,
+ server: "sandbox",
+ });
+ }
+
+ throw new Error("POLAR_ACCESS_TOKEN is not configured");
+ }
+
+ return new Polar({
+ accessToken,
+ server: resolveServer(),
+ });
+}
+
+export function getPolarClient(): Polar {
+ if (!polarClientInstance) {
+ polarClientInstance = createPolarClient();
+ }
+
+ return polarClientInstance;
+}
+
+export const polarClient = new Proxy({} as Polar, {
+ get(_target, prop) {
+ return getPolarClient()[prop as keyof Polar];
+ },
+});
+
+export function isPolarConfigured(): boolean {
+ return (
+ hasEnvVar("POLAR_ACCESS_TOKEN") &&
+ hasEnvVar("NEXT_PUBLIC_POLAR_ORGANIZATION_ID") &&
+ hasEnvVar("POLAR_WEBHOOK_SECRET")
+ );
+}
+
+export function getPolarOrganizationId(): string {
+ const orgId = process.env.NEXT_PUBLIC_POLAR_ORGANIZATION_ID?.trim();
+ if (!orgId) {
+ throw new Error("NEXT_PUBLIC_POLAR_ORGANIZATION_ID is not configured");
+ }
+ return orgId;
+}
+
+export function getPolarProProductId(): string {
+ const productId = process.env.NEXT_PUBLIC_POLAR_PRO_PRODUCT_ID?.trim();
+ if (!productId) {
+ throw new Error("NEXT_PUBLIC_POLAR_PRO_PRODUCT_ID is not configured");
+ }
+ return productId;
+}
+
+export function getPolarWebhookSecret(): string {
+ const secret = process.env.POLAR_WEBHOOK_SECRET?.trim();
+ if (!secret) {
+ throw new Error("POLAR_WEBHOOK_SECRET is not configured");
+ }
+ return secret;
+}
File: src/lib/subscription-metadata.ts
Changes:
@@ -0,0 +1,46 @@
+type SubscriptionMetadata = Record<string, unknown>;
+
+interface IdempotencySource {
+ id: string;
+ updatedAt: string | number | Date;
+ status: string;
+}
+
+function resolveTimestamp(value: string | number | Date): number {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+
+ if (value instanceof Date) {
+ return value.getTime();
+ }
+
+ const parsed = Date.parse(String(value));
+ return Number.isNaN(parsed) ? Date.now() : parsed;
+}
+
+export function sanitizeSubscriptionMetadata(input: unknown): SubscriptionMetadata {
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
+ return {};
+ }
+
+ return input as SubscriptionMetadata;
+}
+
+export function extractUserIdFromMetadata(input: unknown): {
+ metadata: SubscriptionMetadata;
+ userId: string;
+} {
+ const metadata = sanitizeSubscriptionMetadata(input);
+ const userIdValue = metadata.userId;
+ const userId = typeof userIdValue === "string" ? userIdValue.trim() : "";
+
+ return { metadata, userId };
+}
+
+export function buildSubscriptionIdempotencyKey(source: IdempotencySource): string {
+ const updatedAtTimestamp = resolveTimestamp(source.updatedAt);
+ const normalizedStatus = source.status?.toLowerCase() ?? "";
+
+ return `${source.id}:${updatedAtTimestamp}:${normalizedStatus}`;
+}
File: src/prompts/shared.ts
Changes:
@@ -239,7 +239,7 @@ Instructions:
11. Do not assume existing file contents — use readFiles if unsure
12. Do not include any commentary, explanation, or markdown — use only tool outputs
13. When users request database-backed features, default to Drizzle ORM with a Prisma Console–hosted PostgreSQL instance and manage schema via Drizzle migrations.
-14. When users request authentication capabilities, implement them with Better Auth on top of the Drizzle/PostgreSQL setup.
+14. When users request authentication capabilities, implement them with Stack Auth using the official @stackframe/stack patterns and wire it through Convex.
15. Always build full, real-world features or screens — not demos, stubs, or isolated widgets
16. Unless explicitly asked otherwise, always assume the task requires a full page layout — including all structural elements
17. Always implement realistic behavior and interactivity — not just static UI
File: tests/auth-helpers.test.ts
Changes:
@@ -8,7 +8,9 @@ import {
sanitizeSubscriptionMetadata,
} from '../src/lib/subscription-metadata';
-describe('Convex Auth helpers (Better Auth)', () => {
+type JoseJWKSInput = Parameters<typeof createLocalJWKSet>[0];
+
+describe('Convex Auth helpers (Stack Auth)', () => {
describe('subscription metadata parsing', () => {
it('extracts and trims userId from metadata objects', () => {
const { metadata, userId } = extractUserIdFromMetadata({ userId: ' user_123 ', plan: 'pro' });
@@ -19,7 +21,7 @@ describe('Convex Auth helpers (Better Auth)', () => {
it('guards against unexpected metadata shapes', () => {
expect(sanitizeSubscriptionMetadata(null)).toEqual({});
expect(sanitizeSubscriptionMetadata(42)).toEqual({});
- expect(extractUserIdFromMetadata({} as any).userId).toBe('');
+ expect(extractUserIdFromMetadata({}).userId).toBe('');
});
it('builds stable idempotency keys', () => {
@@ -36,11 +38,14 @@ describe('Convex Auth helpers (Better Auth)', () => {
it('signs JWTs with a kid and verifies against JWKS', async () => {
const token = await signConvexJWT({ sub: 'user_abc' });
const jwks = await getJWKS();
- const jwkSet = createLocalJWKSet(jwks as any);
+ const jwkSet = createLocalJWKSet(jwks as JoseJWKSInput);
const { payload, protectedHeader } = await jwtVerify(token, jwkSet, {
audience: 'convex',
- issuer: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || 'http://localhost:3000',
+ issuer:
+ process.env.NEXT_PUBLIC_APP_URL ||
+ process.env.NEXT_PUBLIC_BASE_URL ||
+ 'http://localhost:3000',
});
expect(payload.sub).toBe('user_abc');
|
WalkthroughThis PR migrates authentication from Better Auth to Stack Auth, implements a complete Polar checkout flow with webhook handling, and refines token storage and issuer resolution across auth configuration endpoints. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client/Browser
participant Button as polar-checkout-button.tsx
participant API as /api/polar/create-checkout
participant Polar as Polar Service
participant DB as Convex/DB
Client->>Button: Click "Upgrade"
Button->>Button: Verify authentication
Button->>API: POST /api/polar/create-checkout
API->>API: Validate Polar config
API->>API: Resolve productId
API->>Polar: Create checkout session
Polar-->>API: Checkout session (id, url)
API-->>Button: { checkoutId, url, ... }
Button->>Client: Redirect to Polar checkout
sequenceDiagram
participant Polar as Polar Service
participant Webhook as /api/webhooks/polar
participant Verify as Webhook Verify
participant DB as Convex/DB
Polar->>Webhook: POST event + signature
Webhook->>Verify: Validate webhook-signature header
Verify-->>Webhook: Signature OK
Webhook->>Webhook: Extract & normalize event
Webhook->>DB: Check idempotency key
alt Duplicate Event
DB-->>Webhook: Already processed
Webhook-->>Polar: 200 (idempotent)
else New Event
Webhook->>Webhook: Extract userId from metadata
Webhook->>DB: Upsert subscription (or cancel/revoke)
DB-->>Webhook: Done
Webhook->>DB: Record processed event
Webhook-->>Polar: 200 OK
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
|
🚀 Scrapybara Ubuntu instance started! |
|
🔧 Setting up test environment... Agent Steps |
|
❌ Something went wrong: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| await fetchMutation(api.webhookEvents.recordProcessedEvent, { | ||
| idempotencyKey, | ||
| provider: "polar", | ||
| eventType: eventType || "unknown", | ||
| }); |
There was a problem hiding this comment.
Avoid marking webhook as processed before validation
The handler writes the idempotency record (recordProcessedEvent) before it validates the payload or runs any subscription mutations. If the metadata check below returns 422 or any of the mutation calls throw and return 500, the idempotency row has already been inserted so subsequent Polar redeliveries will be treated as duplicates and skipped. That makes subscription updates irrecoverable after a transient failure. The idempotency insert should happen only after successful processing (or be rolled back on errors) so failed events can be retried.
Useful? React with 👍 / 👎.
Code Review: Stack Auth & Polar.sh MigrationThank you for this comprehensive migration! I've reviewed the changes and have feedback organized by category: StrengthsWell-Structured Architecture
Security Considerations
Critical Issues1. Missing CSRF Protection in Webhook HandlerLocation: src/app/api/webhooks/polar/route.ts The webhook endpoint lacks CSRF token verification. While webhook signature verification is present, Next.js middleware CSRF protection should be explicitly configured. Recommendation: Add route configuration to bypass CSRF for webhooks (as webhooks are verified via signature) 2. Race Condition in Webhook IdempotencyLocation: convex/webhookEvents.ts:66-86 The double-check pattern after insert doesn't fully prevent race conditions in distributed systems. Convex mutations are transactional, but the logic could still allow duplicates in edge cases. Recommendation: Use Convex's built-in unique constraints or move to a try-catch pattern 3. Unsafe Type CoercionLocation: src/app/api/webhooks/polar/route.ts:42 The toObject function casts without validation. If value has prototypes with malicious properties, it could lead to prototype pollution. Recommendation: Use Object.create(null, Object.getOwnPropertyDescriptors(value)) instead Security Concerns4. Webhook Replay Attack WindowLocation: convex/webhookEvents.ts:4 A 5-minute TTL allows attackers to replay webhooks captured within that timeframe. Industry standard is 24 hours or longer. Recommendation: Increase IDEMPOTENCY_TTL_MS to 24-48 hours 5. Incomplete Webhook Event Type HandlingLocation: src/app/api/webhooks/polar/route.ts:189-205 Only 6 event types are handled, but Polar may send others (subscription.past_due, subscription.unpaid, payment failures, etc.). These silently succeed without logging. Recommendation: Add logging for unhandled events in the default case Important Issues6. Hardcoded User Metadata ExtractionLocation: src/lib/subscription-metadata.ts:30-37 The code assumes userId is always in metadata, but Polar's customer object has its own ID structure. If metadata is missing, the webhook fails with a 422. Recommendation: Implement fallback logic to check metadata.userId, metadata.user_id, and metadata.stackUserId 7. Missing Input Validation for Product IDLocation: src/app/api/polar/create-checkout/route.ts:58-59 User-provided productId is used without validation against allowed product IDs. Recommendation: Validate against an allowlist of product IDs 8. Lack of Rate Limiting on Checkout EndpointLocation: src/app/api/polar/create-checkout/route.ts No rate limiting is implemented, allowing abuse by creating unlimited checkout sessions. Recommendation: Implement rate limiting using Vercel KV or Upstash 9. Subscription Status Normalization GapsLocation: src/app/api/webhooks/polar/route.ts:56-68 The normalizeStatus function defaults unknown statuses to incomplete, which may be incorrect for statuses like paused or trialing. Recommendation: Log unknown statuses and handle explicitly Code Quality Improvements10. Inconsistent Error Response StructureLocation: src/app/api/polar/create-checkout/route.ts The buildResponse function returns different shapes but there's no TypeScript interface enforcing this. Recommendation: Define an ErrorResponse interface 11. Duplicated Base URL LogicLocation: Multiple files Multiple files duplicate the same env var fallback logic for base URL. Recommendation: Centralize in a config file (e.g., src/lib/config.ts) 12. Missing Retry Logic for Polar API CallsLocation: src/app/api/polar/create-checkout/route.ts:88 Polar API calls don't have retry logic for transient failures. Recommendation: Implement exponential backoff retry logic Testing Gaps13. No Tests for Polar IntegrationLocation: tests/ directory There are no tests for:
Recommendation: Add tests/polar-integration.test.ts 14. No E2E Tests for Checkout FlowConsider adding Playwright/Cypress tests for the full checkout user journey. Performance Considerations15. Inefficient Webhook Event CleanupLocation: convex/webhookEvents.ts:96-115 The cleanup function queries all expired events then deletes them one by one in a loop, which is inefficient for large datasets. Recommendation: Use batch deletion or limit cleanup size to 100 records per run 16. Missing Index on webhookEvents.expiresAtLocation: convex/schema.ts Cleanup queries by expiresAt but verify this index exists in the schema. Recommendation: Ensure by_expiresAt index is defined Minor Improvements
SummaryThis is a solid migration with good architectural decisions. Main areas for improvement:
Recommendation: Address the Critical and Security issues before merging. The other improvements can be follow-up PRs. Overall Assessment: 4/5 - Very good work with some important security considerations to address. Related Files to Check
Great work on this migration! Let me know if you'd like help implementing any improvements. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (6)
src/app/.well-known/openid-configuration/route.ts (1)
4-7: Issuer/baseUrl precedence looks good; consider centralizing logicUsing
NEXT_PUBLIC_APP_URL→NEXT_PUBLIC_BASE_URL→"http://localhost:3000"keeps issuer aligned with the JWT signing logic insrc/lib/convex-auth.ts. To avoid future drift, consider factoring this into a shared helper (e.g., exportinggetAppIssuer) and reusing it here and in tests.src/components/polar-checkout-button.tsx (1)
35-97: Polar checkout flow and error handling are robust; minor loading-state nitThe updated handler cleanly guards on auth, calls the Polar checkout API, interprets structured error payloads (
error,details, optionaladminMessage), and covers both HTTP and network failures while always resettingisLoadinginfinally. The only minor nit is thatsetIsLoading(false)is called both inside the unauthenticated branch and again infinally, which is harmless but slightly redundant—relying solely on thefinallyblock would be a small simplification.src/app/api/polar/create-checkout/route.ts (2)
48-80: Config validation path could distinguish config vs internal errors
isPolarConfigured()returns a clear 503 withisConfigError, but any later failure invalidatePolarEnv(true)orgetPolarClient()will currently surface as a generic 500 from the catch block, losing that explicit configuration signal.If you want ops to quickly see misconfiguration vs internal failures, consider catching
validatePolarEnv/ Polar config errors separately and returning a 503 withisConfigError: truesimilar to the early guard.
103-110: Optional: usebuildResponsefor success for symmetryRight now errors go through
buildResponsewhile the success payload usesNextResponse.jsondirectly. Not an issue, but if you want consistent response shaping and easier future extensions (e.g. adding common metadata), you could wrap the success response inbuildResponse(200, ...)as well.src/lib/subscription-metadata.ts (1)
41-46: Idempotency key design is reasonable; consider timestamp fallback semantics
buildSubscriptionIdempotencyKeycomposes a stable key from id, normalized timestamp, and status, which is exactly what you want for webhook idempotency.Given that callers (e.g. the Polar webhook route) already normalize
updatedAtto a sensible default, theDate.now()fallback inresolveTimestampshould be rare. If you ever wire this helper elsewhere, keep in mind that a bad timestamp will produce a different idempotency key on each retry; if that becomes an issue, you might want to change the fallback to a fixed sentinel (e.g.0) instead ofDate.now().src/app/api/webhooks/polar/route.ts (1)
126-140: Differentiate missing webhook secret from invalid signatureRight now, if
getPolarWebhookSecret()throws (e.g. missingPOLAR_WEBHOOK_SECRET), the error is caught and surfaced asInvalid webhook signaturewith a 400, which hides the underlying configuration issue.Consider pulling the secret resolution out into its own guarded step and returning a 503-style config error when it fails, e.g.:
let secret: string; try { secret = getPolarWebhookSecret(); } catch (error) { console.error("Polar webhook secret not configured:", error); return NextResponse.json( { error: "Polar webhook is not configured" }, { status: 503 }, ); } const rawBody = await request.text(); try { const headers = Object.fromEntries(request.headers.entries()); const verified = validateEvent(rawBody, headers, secret); // ... } catch (error) { // keep current invalid-signature handling }This keeps operational/debugging signals clearer without changing the happy path.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (12)
convex/helpers.ts(1 hunks)convex/schema.ts(7 hunks)src/app/.well-known/openid-configuration/route.ts(1 hunks)src/app/api/polar/create-checkout/route.ts(1 hunks)src/app/api/webhooks/polar/route.ts(1 hunks)src/components/convex-provider.tsx(1 hunks)src/components/polar-checkout-button.tsx(1 hunks)src/lib/convex-auth.ts(2 hunks)src/lib/polar-client.ts(1 hunks)src/lib/subscription-metadata.ts(1 hunks)src/prompts/shared.ts(1 hunks)tests/auth-helpers.test.ts(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
src/lib/polar-client.ts (1)
src/lib/env-validation.ts (2)
validatePolarEnv(18-111)hasEnvVar(142-145)
tests/auth-helpers.test.ts (1)
src/lib/subscription-metadata.ts (1)
extractUserIdFromMetadata(30-39)
src/app/api/webhooks/polar/route.ts (3)
src/lib/subscription-metadata.ts (3)
sanitizeSubscriptionMetadata(22-28)buildSubscriptionIdempotencyKey(41-46)extractUserIdFromMetadata(30-39)convex/subscriptions.ts (2)
revokeSubscription(171-195)reactivateSubscription(143-166)src/lib/polar-client.ts (1)
getPolarWebhookSecret(89-95)
src/app/api/polar/create-checkout/route.ts (3)
src/lib/stack-auth.ts (1)
getUser(40-48)src/lib/polar-client.ts (4)
isPolarConfigured(65-71)getPolarProProductId(81-87)getPolarClient(51-57)getPolarOrganizationId(73-79)src/lib/env-validation.ts (2)
validatePolarEnv(18-111)getSanitizedErrorDetails(150-169)
🪛 GitHub Actions: CI
src/app/api/webhooks/polar/route.ts
[error] 130-130: TS2345: Argument of type 'string' is not assignable to parameter of type 'Record<string, string>'.
src/app/api/polar/create-checkout/route.ts
[error] 90-90: TS2339: Property 'checkoutSessions' does not exist on type 'Polar'.
[error] 96-96: TS2339: Property 'name' does not exist on type 'CurrentServerUser'.
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Codacy Security Scan
- GitHub Check: claude-review
🔇 Additional comments (11)
convex/helpers.ts (1)
67-75: Legacy alias docs now correctly reference Stack Auth IDsThe deprecation notice and description for
getCurrentUserClerkIdnow accurately describe it as an alias for Stack Auth identifiers, matching the rest of the auth migration work. No functional concerns.convex/schema.ts (1)
68-75: User ID field docs now aligned with Stack Auth usageThe updated comments for
userIdacross these tables correctly clarify that you’re storing the Stack Auth identifier as a string (not a Convexv.id), which matches how helpers likegetCurrentUserIdandhasProAccessuse these fields. No schema or index changes introduced here.Also applies to: 136-147, 152-165, 170-177, 192-211, 218-229, 268-291
src/prompts/shared.ts (1)
241-244: Prompt guidance updated to match Stack Auth + Convex integrationThe revised rule explicitly steering auth implementations toward Stack Auth + Convex is consistent with the rest of this PR and should help keep generated code aligned with the new stack.
src/components/convex-provider.tsx (1)
13-20: Convex auth wiring with Stack Auth looks correct; verify tokenStore optionConfiguring
convex.setAuthwithstackApp.getConvexClientAuth({ tokenStore: "nextjs-cookie" })matches the expected pattern for cookie-based auth in a Next.js app using Stack Auth and Convex.Please double-check against the current
@stackframe/stackdocs that"nextjs-cookie"remains the correcttokenStoreoption for Convex auth in your target version.tests/auth-helpers.test.ts (1)
11-25: Typed JWKS input and issuer precedence tests look soundUsing
JoseJWKSInput = Parameters<typeof createLocalJWKSet>[0]tightens typings aroundjwksand removes theanycast, and the issuer precedence in the JWT test mirrors the newgetAppIssuerbehavior (APP_URL → BASE_URL → localhost). The metadata expectations also align withextractUserIdFromMetadatatrimming behavior.Since this relies on
jose’screateLocalJWKSetsignature and JWT verification semantics, please confirm against your installedjoseversion that the parameter type and issuer handling remain compatible.Also applies to: 37-49
src/lib/convex-auth.ts (1)
1-1: JWT payload typing and centralized issuer helper are solid improvementsSwitching
signConvexJWTto accept aJWTPayloadand delegating issuer resolution togetAppIssuer()(APP_URL → BASE_URL → localhost) tightens type safety and keeps issuer derivation consistent with the rest of the stack. This should make JWT signing more robust while aligning with the updated OpenID configuration and tests.Because this depends on
jose’sJWTPayloadandSignJWTAPIs, please confirm against yourjoseversion that the payload type and chainedsetIssuer/setAudience/setExpirationTimeusage are still recommended and supported.Also applies to: 216-237
src/app/api/polar/create-checkout/route.ts (1)
18-36: Helper-based error responses look good
getBaseUrlandbuildResponsegive you a consistent error payload and HTTP status handling, which keeps the rest of the handler focused on business logic.src/lib/polar-client.ts (2)
27-57: Polar client factory and lazy singleton look solid
createPolarClient+getPolarClientgive you a single SDK instance, run full env validation outside the production build phase, and fall back to a sandbox placeholder during builds, which should prevent build-time crashes while still surfacing runtime misconfigurations.No changes needed from my side here.
65-87: Config helpers are clear and focused
isPolarConfigured,getPolarOrganizationId, andgetPolarProProductIdare small, purpose-built helpers that cleanly separate presence-checks from throwing accessors. This lines up well with how they’re used in the checkout route.Looks good.
src/lib/subscription-metadata.ts (1)
22-39: Metadata sanitization and userId extraction are appropriate
sanitizeSubscriptionMetadataandextractUserIdFromMetadatadefensively handle arbitrary input and give you a simple{ metadata, userId }contract that works cleanly with the webhook route logic.This is a good balance between type safety and runtime robustness.
src/app/api/webhooks/polar/route.ts (1)
154-220: Webhook idempotency and subscription routing look goodThe combination of
buildSubscriptionIdempotencyKey,api.webhookEvents.isDuplicate/recordProcessedEvent, and the event-type switch gives you at-most-once processing with clear mapping to the Convex subscription mutations.The fallback to
subscription.metadata ?? subscription.customer?.metadataand use ofextractUserIdFromMetadatais a sensible way to bind events to users, and the 422 on missinguserIdprovides a clear signal without retry loops.No changes needed here from my side.
| const checkout = await polar.checkoutSessions.create({ | ||
| organizationId: getPolarOrganizationId(), | ||
| productPriceId: productId, | ||
| successUrl, | ||
| cancelUrl, | ||
| customerEmail: user.primaryEmail ?? undefined, | ||
| customerName: user.name ?? undefined, | ||
| metadata: { | ||
| userId: user.id, | ||
| userEmail: user.primaryEmail ?? undefined, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
Use polar.checkouts.create instead of checkoutSessions.create
The CI error stems from calling polar.checkoutSessions.create on the Polar client; the current SDK exposes the checkout API under polar.checkouts.create(...), not checkoutSessions. (polar.sh)
Also, the Checkout API expects product IDs via products: string[] rather than a single productPriceId.
Consider updating this block along these lines:
- const checkout = await polar.checkoutSessions.create({
- organizationId: getPolarOrganizationId(),
- productPriceId: productId,
- successUrl,
- cancelUrl,
- customerEmail: user.primaryEmail ?? undefined,
- customerName: user.name ?? undefined,
- metadata: {
- userId: user.id,
- userEmail: user.primaryEmail ?? undefined,
- },
- });
+ const checkout = await polar.checkouts.create({
+ products: [productId],
+ successUrl,
+ cancelUrl,
+ customerEmail: user.primaryEmail ?? undefined,
+ customerName: user.displayName ?? undefined,
+ metadata: {
+ userId: user.id,
+ userEmail: user.primaryEmail ?? undefined,
+ },
+ });Please verify the exact option names against the installed @polar-sh/sdk version and add any additional fields you need (e.g. externalCustomerId) to align with your webhook mapping.
🌐 Web query:
What is the current TypeScript signature for `checkouts.create` in `@polar-sh/sdk`, including how to pass product IDs, redirect URLs, and customer metadata?
💡 Result:
Short answer — signature and how to pass fields
TypeScript signature (shape used by @polar-sh/sdk — camelCased by the TS SDK):
const checkout = await polar.checkouts.create({
products: string[], // required — list of product IDs (first is default)
successUrl?: string, // optional — redirect after successful payment
returnUrl?: string, // optional — back button return URL
metadata?: Record<string, string|number>, // optional — checkout metadata copied to Order/Subscription
customerMetadata?: Record<string, string|number|boolean|number>, // optional — copied to created Customer
externalCustomerId?: string, // optional — your own customer id (external_customer_id)
trialInterval?: 'day'|'week'|'month'|'year'|null,
trialIntervalCount?: number|null,
embedOrigin?: string|null,
// ...other optional fields described in API
});
(Fields are camelCased by the TS SDK; the underlying API uses snake_case.)
Example minimal usage:
import { Polar } from "@polar-sh/sdk";
const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN ?? "" });
const checkout = await polar.checkouts.create({
products: ["prod_abc123", "prod_def456"],
successUrl: "https://example.com/thank-you?checkout_id={CHECKOUT_ID}",
returnUrl: "https://example.com/pricing",
metadata: { campaign: "black-friday" },
customerMetadata: { accountId: "acct_987" },
});
Sources: Polar docs — Create Checkout Session and TypeScript SDK (camelCase note). [1][2]
References
[1] Create Checkout Session (checkouts/create) — Polar docs.
[2] TypeScript SDK — Polar docs (camelCase conversion note).
Use polar.checkouts.create instead of checkoutSessions.create and correct the redirect URL parameter
The current call to polar.checkoutSessions.create does not exist in @polar-sh/sdk; use polar.checkouts.create instead. The API expects product IDs via products: string[] (not productPriceId), but the suggested diff contains an error: the parameter is returnUrl (not cancelUrl). Also, organizationId is not part of the new API signature.
The Checkout API also provides customerMetadata (for customer-related data) separate from metadata (for order/subscription data), which may be more appropriate for your user fields.
Update the code:
- const checkout = await polar.checkoutSessions.create({
- organizationId: getPolarOrganizationId(),
- productPriceId: productId,
+ const checkout = await polar.checkouts.create({
+ products: [productId],
successUrl,
- cancelUrl,
+ returnUrl,
customerEmail: user.primaryEmail ?? undefined,
- customerName: user.name ?? undefined,
+ customerName: user.displayName ?? undefined,
metadata: {
userId: user.id,
userEmail: user.primaryEmail ?? undefined,
},
});Verify that user.displayName is the correct property on your User object (the original used user.name).
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const checkout = await polar.checkoutSessions.create({ | |
| organizationId: getPolarOrganizationId(), | |
| productPriceId: productId, | |
| successUrl, | |
| cancelUrl, | |
| customerEmail: user.primaryEmail ?? undefined, | |
| customerName: user.name ?? undefined, | |
| metadata: { | |
| userId: user.id, | |
| userEmail: user.primaryEmail ?? undefined, | |
| }, | |
| }); | |
| const checkout = await polar.checkouts.create({ | |
| products: [productId], | |
| successUrl, | |
| returnUrl, | |
| customerEmail: user.primaryEmail ?? undefined, | |
| customerName: user.displayName ?? undefined, | |
| metadata: { | |
| userId: user.id, | |
| userEmail: user.primaryEmail ?? undefined, | |
| }, | |
| }); |
🧰 Tools
🪛 GitHub Actions: CI
[error] 90-90: TS2339: Property 'checkoutSessions' does not exist on type 'Polar'.
[error] 96-96: TS2339: Property 'name' does not exist on type 'CurrentServerUser'.
🤖 Prompt for AI Agents
In src/app/api/polar/create-checkout/route.ts around lines 90 to 101, replace
the nonexistent polar.checkoutSessions.create call with polar.checkouts.create
and update the parameters: remove organizationId, replace productPriceId with
products: [productId], use returnUrl (not cancelUrl) for the redirect on cancel,
and move user-level fields into customerMetadata (e.g., customerEmail and
customerName) while keeping order/subscription fields like userId and userEmail
in metadata; also confirm whether user.displayName should be used instead of
user.name and wire that property into customerName if appropriate.
| customerEmail: user.primaryEmail ?? undefined, | ||
| customerName: user.name ?? undefined, | ||
| metadata: { | ||
| userId: user.id, | ||
| userEmail: user.primaryEmail ?? undefined, |
There was a problem hiding this comment.
🧩 Analysis chain
Fix Stack Auth user field: use displayName instead of name
CurrentServerUser (the return type of stackServerApp.getUser()) inherits from CurrentUser, which exposes displayName, not name. (docs.stack-auth.com) This causes the Property 'name' does not exist on type 'CurrentServerUser' error.
Change the customer name mapping to use displayName:
- customerName: user.name ?? undefined,
+ customerName: user.displayName ?? undefined,🌐 Web query:
In the latest Stack Auth docs, which field on `CurrentServerUser` should be used as the human-readable name: `name` or `displayName`?
💡 Result:
Use displayName — CurrentServerUser inherits CurrentUser which exposes displayName (not name). [1][2]
Sources:
[1] Stack Auth docs — CurrentServerUser / CurrentUser type reference.
[2] Stack Auth docs (next) — CurrentUser.displayName description.
Fix Stack Auth user field: use displayName instead of name
CurrentServerUser (the return type of stackServerApp.getUser()) inherits from CurrentUser, which exposes displayName, not name. This causes the Property 'name' does not exist on type 'CurrentServerUser' error.
Change the customer name mapping to use displayName:
- customerName: user.name ?? undefined,
+ customerName: user.displayName ?? undefined,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| customerEmail: user.primaryEmail ?? undefined, | |
| customerName: user.name ?? undefined, | |
| metadata: { | |
| userId: user.id, | |
| userEmail: user.primaryEmail ?? undefined, | |
| customerEmail: user.primaryEmail ?? undefined, | |
| customerName: user.displayName ?? undefined, | |
| metadata: { | |
| userId: user.id, | |
| userEmail: user.primaryEmail ?? undefined, |
🧰 Tools
🪛 GitHub Actions: CI
[error] 96-96: TS2339: Property 'name' does not exist on type 'CurrentServerUser'.
🤖 Prompt for AI Agents
In src/app/api/polar/create-checkout/route.ts around lines 95 to 99, the code
references user.name which does not exist on CurrentServerUser; replace that
usage with user.displayName (preserving the existing nullish fallback to
undefined) so customerName: user.displayName ?? undefined. Ensure any other
occurrences in that block or metadata mapping use displayName instead of name.
| export async function POST(request: NextRequest) { | ||
| const signature = request.headers.get("webhook-signature"); | ||
| if (!signature) { | ||
| return NextResponse.json( | ||
| { error: "Missing webhook signature" }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| const rawBody = await request.text(); | ||
|
|
||
| let parsedEvent: Record<string, unknown>; | ||
| try { | ||
| const verified = validateEvent(rawBody, signature, getPolarWebhookSecret()); | ||
| parsedEvent = toObject( | ||
| typeof verified === "string" ? JSON.parse(verified) : verified, | ||
| ); | ||
| } catch (error) { | ||
| console.error("Polar webhook signature verification failed:", error); | ||
| return NextResponse.json( | ||
| { error: "Invalid webhook signature" }, | ||
| { status: 400 }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
Fix validateEvent call: pass headers object, not a single header string
The CI error on Line 130 is because validateEvent expects a Record<string, string> for the headers argument, but you’re passing the webhook-signature string. The Polar docs and examples show calling it as validateEvent(payload, headers, secret). (github.com)
You can adapt this block as follows:
- const rawBody = await request.text();
-
- let parsedEvent: Record<string, unknown>;
- try {
- const verified = validateEvent(rawBody, signature, getPolarWebhookSecret());
+ const rawBody = await request.text();
+
+ let parsedEvent: Record<string, unknown>;
+ try {
+ const headers = Object.fromEntries(request.headers.entries());
+ const verified = validateEvent(rawBody, headers, getPolarWebhookSecret());
parsedEvent = toObject(
typeof verified === "string" ? JSON.parse(verified) : verified,
);
} catch (error) {You can keep the explicit signature presence check as-is for nicer 400 responses when the header is missing.
🌐 Web query:
What is the current TypeScript signature of `validateEvent` from `@polar-sh/sdk/webhooks`, and what argument types are expected for payload, headers, and secret?
💡 Result:
Short answer — as exposed by @polar-sh/sdk/webhooks the function is used as:
validateEvent(payload, headers, secret)
Expected argument types:
- payload: the raw request body (e.g. Express's req.body when using express.raw — string | Buffer/Uint8Array containing the JSON payload). [1][2]
- headers: the incoming request headers (e.g. Express req.headers or a Record<string,string>). [1][2]
- secret: your webhook secret as a string (the secret you set in Polar; note Polar expects a base64-encoded secret for signature verification). [1][3]
References:
- Polar docs — Validate & parse webhooks (usage example showing validateEvent(req.body, req.headers, POLAR_WEBHOOK_SECRET)). [1]
- Polar TypeScript SDK docs / README examples showing same usage. [2]
- Polar Webhooks setup docs (notes about secret/base64). [3]
If you want, I can fetch the exact .d.ts/implementation from the SDK package to show the precise TypeScript types.
Fix validateEvent call: pass headers object, not a single header string
The code on line 130 passes signature (a string extracted from request.headers.get("webhook-signature")) as the second argument to validateEvent, but the SDK expects a headers object (Record<string, string>). The correct usage is validateEvent(payload, headers, secret) where headers should be the incoming request headers like Express req.headers or a Record<string,string>.
Update the code as follows:
const rawBody = await request.text();
let parsedEvent: Record<string, unknown>;
try {
+ const headers = Object.fromEntries(request.headers.entries());
- const verified = validateEvent(rawBody, signature, getPolarWebhookSecret());
+ const verified = validateEvent(rawBody, headers, getPolarWebhookSecret());
parsedEvent = toObject(
typeof verified === "string" ? JSON.parse(verified) : verified,
);The explicit signature presence check (lines 119–122) can remain as-is for better error messaging.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function POST(request: NextRequest) { | |
| const signature = request.headers.get("webhook-signature"); | |
| if (!signature) { | |
| return NextResponse.json( | |
| { error: "Missing webhook signature" }, | |
| { status: 400 }, | |
| ); | |
| } | |
| const rawBody = await request.text(); | |
| let parsedEvent: Record<string, unknown>; | |
| try { | |
| const verified = validateEvent(rawBody, signature, getPolarWebhookSecret()); | |
| parsedEvent = toObject( | |
| typeof verified === "string" ? JSON.parse(verified) : verified, | |
| ); | |
| } catch (error) { | |
| console.error("Polar webhook signature verification failed:", error); | |
| return NextResponse.json( | |
| { error: "Invalid webhook signature" }, | |
| { status: 400 }, | |
| ); | |
| } | |
| export async function POST(request: NextRequest) { | |
| const signature = request.headers.get("webhook-signature"); | |
| if (!signature) { | |
| return NextResponse.json( | |
| { error: "Missing webhook signature" }, | |
| { status: 400 }, | |
| ); | |
| } | |
| const rawBody = await request.text(); | |
| let parsedEvent: Record<string, unknown>; | |
| try { | |
| const headers = Object.fromEntries(request.headers.entries()); | |
| const verified = validateEvent(rawBody, headers, getPolarWebhookSecret()); | |
| parsedEvent = toObject( | |
| typeof verified === "string" ? JSON.parse(verified) : verified, | |
| ); | |
| } catch (error) { | |
| console.error("Polar webhook signature verification failed:", error); | |
| return NextResponse.json( | |
| { error: "Invalid webhook signature" }, | |
| { status: 400 }, | |
| ); | |
| } |
🧰 Tools
🪛 GitHub Actions: CI
[error] 130-130: TS2345: Argument of type 'string' is not assignable to parameter of type 'Record<string, string>'.
🤖 Prompt for AI Agents
In src/app/api/webhooks/polar/route.ts around lines 117 to 140, the call to
validateEvent is passing the single header string `signature` as the second
argument but the SDK expects a headers object (Record<string,string>) as the
second parameter; replace the string with the request headers object (e.g.,
build a Record via Object.fromEntries(request.headers) or similar) and pass that
headers object along with the rawBody and secret to validateEvent; keep the
existing explicit signature presence check intact for error messaging.
Description
Migrates authentication from Better Auth to Stack Auth and integrates Polar.sh for subscription management.
Changes
Updated schema for Stack Auth user IDs, implemented Polar.sh checkout API, added Polar webhooks, and configured Convex for Stack Auth.
Summary by CodeRabbit
New Features
Bug Fixes
Chores
✏️ Tip: You can customize this high-level summary in your review settings.