From 294977fc45e28ed06f58cc2aa695f239ab055e5e Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo-io[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 04:12:21 +0000 Subject: [PATCH] feat: Migrate to Stack Auth and integrate Polar.sh Co-authored-by: otdoges --- convex/helpers.ts | 2 +- convex/schema.ts | 14 +- .../.well-known/openid-configuration/route.ts | 5 +- src/app/api/polar/create-checkout/route.ts | 139 +++++++++-- src/app/api/webhooks/polar/route.ts | 221 ++++++++++++++++++ src/components/convex-provider.tsx | 6 +- src/components/polar-checkout-button.tsx | 72 +++--- src/lib/convex-auth.ts | 14 +- src/lib/polar-client.ts | 95 ++++++++ src/lib/subscription-metadata.ts | 46 ++++ src/prompts/shared.ts | 2 +- tests/auth-helpers.test.ts | 13 +- 12 files changed, 563 insertions(+), 66 deletions(-) create mode 100644 src/app/api/webhooks/polar/route.ts create mode 100644 src/lib/polar-client.ts create mode 100644 src/lib/subscription-metadata.ts diff --git a/convex/helpers.ts b/convex/helpers.ts index 9b974112..d6869224 100644 --- a/convex/helpers.ts +++ b/convex/helpers.ts @@ -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( diff --git a/convex/schema.ts b/convex/schema.ts index cdbbabec..5cf4f383 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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( diff --git a/src/app/.well-known/openid-configuration/route.ts b/src/app/.well-known/openid-configuration/route.ts index 1b24ba4f..cc64f19c 100644 --- a/src/app/.well-known/openid-configuration/route.ts +++ b/src/app/.well-known/openid-configuration/route.ts @@ -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`, diff --git a/src/app/api/polar/create-checkout/route.ts b/src/app/api/polar/create-checkout/route.ts index 07f6e531..1fa29da6 100644 --- a/src/app/api/polar/create-checkout/route.ts +++ b/src/app/api/polar/create-checkout/route.ts @@ -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, + }); } } diff --git a/src/app/api/webhooks/polar/route.ts b/src/app/api/webhooks/polar/route.ts new file mode 100644 index 00000000..9ab8c63c --- /dev/null +++ b/src/app/api/webhooks/polar/route.ts @@ -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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +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; + 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 }); +} diff --git a/src/components/convex-provider.tsx b/src/components/convex-provider.tsx index 92fa549c..324688ac 100644 --- a/src/components/convex-provider.tsx +++ b/src/components/convex-provider.tsx @@ -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 ( diff --git a/src/components/polar-checkout-button.tsx b/src/components/polar-checkout-button.tsx index f3a8afa4..d6acb174 100644 --- a/src/components/polar-checkout-button.tsx +++ b/src/components/polar-checkout-button.tsx @@ -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 ( diff --git a/src/lib/convex-auth.ts b/src/lib/convex-auth.ts index ddb1573e..78fc65c5 100644 --- a/src/lib/convex-auth.ts +++ b/src/lib/convex-auth.ts @@ -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); diff --git a/src/lib/polar-client.ts b/src/lib/polar-client.ts new file mode 100644 index 00000000..2b014c33 --- /dev/null +++ b/src/lib/polar-client.ts @@ -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; +} diff --git a/src/lib/subscription-metadata.ts b/src/lib/subscription-metadata.ts new file mode 100644 index 00000000..1cb0cc6b --- /dev/null +++ b/src/lib/subscription-metadata.ts @@ -0,0 +1,46 @@ +type SubscriptionMetadata = Record; + +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}`; +} diff --git a/src/prompts/shared.ts b/src/prompts/shared.ts index e1b7fb21..27c545b3 100644 --- a/src/prompts/shared.ts +++ b/src/prompts/shared.ts @@ -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 diff --git a/tests/auth-helpers.test.ts b/tests/auth-helpers.test.ts index e7c89469..8deec915 100644 --- a/tests/auth-helpers.test.ts +++ b/tests/auth-helpers.test.ts @@ -8,7 +8,9 @@ import { sanitizeSubscriptionMetadata, } from '../src/lib/subscription-metadata'; -describe('Convex Auth helpers (Better Auth)', () => { +type JoseJWKSInput = Parameters[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');