diff --git a/.github/workflows/check-prisma-migrations.yaml b/.github/workflows/check-prisma-migrations.yaml index 81920fc46..57b1831d8 100644 --- a/.github/workflows/check-prisma-migrations.yaml +++ b/.github/workflows/check-prisma-migrations.yaml @@ -53,6 +53,9 @@ jobs: - name: Build packages run: pnpm run build:packages + - name: Codegen + run: pnpm run codegen + - name: Initialize database run: pnpm run db:init diff --git a/apps/backend/prisma/migrations/20251009231948_enable_and_pin_apps/migration.sql b/apps/backend/prisma/migrations/20251009231948_enable_and_pin_apps/migration.sql index 4a01ec624..0a3e9e5be 100644 --- a/apps/backend/prisma/migrations/20251009231948_enable_and_pin_apps/migration.sql +++ b/apps/backend/prisma/migrations/20251009231948_enable_and_pin_apps/migration.sql @@ -2,7 +2,7 @@ -- SPLIT_STATEMENT_SENTINEL -- SINGLE_STATEMENT_SENTINEL -- RUN_OUTSIDE_TRANSACTION_SENTINEL -CREATE INDEX CONCURRENTLY IF NOT EXISTS "temp_eco_config_apps_idx" ON "EnvironmentConfigOverride" USING GIN ("config"); +CREATE INDEX CONCURRENTLY IF NOT EXISTS "temp_eco_config_apps_idx" ON /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" USING GIN ("config"); -- SPLIT_STATEMENT_SENTINEL -- SPLIT_STATEMENT_SENTINEL diff --git a/apps/backend/src/auto-migrations/auto-migration.tests.ts b/apps/backend/src/auto-migrations/auto-migration.tests.ts index 4708e67c1..386022b35 100644 --- a/apps/backend/src/auto-migrations/auto-migration.tests.ts +++ b/apps/backend/src/auto-migrations/auto-migration.tests.ts @@ -212,9 +212,12 @@ import.meta.vitest?.test("applies migrations concurrently", runTest(async ({ exp })); import.meta.vitest?.test("applies migrations concurrently with 20 concurrent migrations", runTest(async ({ expect, prismaClient }) => { - const promises = Array.from({ length: 20 }, () => - applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, artificialDelayInSeconds: 1, schema: 'public' }) - ); + const promises = Array.from({ length: 20 }, async (_, i) => { + console.log("Applying migration", i); + const result = await applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, artificialDelayInSeconds: 1, schema: 'public', logging: true }); + console.log("Migration", i, "applied", result.newlyAppliedMigrationNames); + return result; + }); const results = await Promise.all(promises); diff --git a/apps/backend/src/auto-migrations/index.tsx b/apps/backend/src/auto-migrations/index.tsx index efd72c1a2..7ed378f48 100644 --- a/apps/backend/src/auto-migrations/index.tsx +++ b/apps/backend/src/auto-migrations/index.tsx @@ -1,4 +1,4 @@ -import { sqlQuoteIdent } from '@/prisma-client'; +import { sqlQuoteIdent, sqlQuoteIdentToString } from '@/prisma-client'; import { Prisma, PrismaClient } from '@prisma/client'; import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; import { MIGRATION_FILES } from './../generated/migration-files'; @@ -125,7 +125,8 @@ export async function applyMigrations(options: { return; } - for (const statement of migration.sql.split('SPLIT_STATEMENT_SENTINEL')) { + for (const statementRaw of migration.sql.split('SPLIT_STATEMENT_SENTINEL')) { + const statement = statementRaw.replace('/* SCHEMA_NAME_SENTINEL */', sqlQuoteIdentToString(options.schema)); const runOutside = statement.includes('RUN_OUTSIDE_TRANSACTION_SENTINEL'); const isSingleStatement = statement.includes('SINGLE_STATEMENT_SENTINEL'); const isConditionallyRepeatMigration = statement.includes('CONDITIONALLY_REPEAT_MIGRATION_SENTINEL'); @@ -197,6 +198,7 @@ export async function runMigrationNeeded(options: { schema: string, migrationFiles?: { migrationName: string, sql: string }[], artificialDelayInSeconds?: number, + logging?: boolean, }): Promise { const migrationFiles = options.migrationFiles ?? MIGRATION_FILES; @@ -217,7 +219,7 @@ export async function runMigrationNeeded(options: { migrationFiles: options.migrationFiles, artificialDelayInSeconds: options.artificialDelayInSeconds, schema: options.schema, - logging: true, + logging: options.logging, }); } else { throw e; diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index c613d8f83..9b5f8aa29 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -93,12 +93,12 @@ export async function getPrismaClientForSourceOfTruth(sourceOfTruth: CompleteCon const entry = sourceOfTruth.connectionStrings[branchId]; const connectionString = await resolveNeonConnectionString(entry); const neonPrismaClient = getNeonPrismaClient(connectionString); - await runMigrationNeeded({ prismaClient: neonPrismaClient, schema: getSchemaFromConnectionString(connectionString) }); + await runMigrationNeeded({ prismaClient: neonPrismaClient, schema: getSchemaFromConnectionString(connectionString), logging: true }); return neonPrismaClient; } case 'postgres': { const postgresPrismaClient = getPostgresPrismaClient(sourceOfTruth.connectionString); - await runMigrationNeeded({ prismaClient: postgresPrismaClient.client, schema: getSchemaFromConnectionString(sourceOfTruth.connectionString) }); + await runMigrationNeeded({ prismaClient: postgresPrismaClient.client, schema: getSchemaFromConnectionString(sourceOfTruth.connectionString), logging: true }); return postgresPrismaClient.client; } case 'hosted': { @@ -365,11 +365,14 @@ export function isPrismaUniqueConstraintViolation(error: unknown, modelName: str return error.meta.modelName === modelName && deepPlainEquals(error.meta.target, target); } -export function sqlQuoteIdent(id: string) { - // accept letters, numbers, underscore, $, and dash (adjust as needed) +export function sqlQuoteIdentToString(id: string) { if (!/^[A-Za-z_][A-Za-z0-9_\-$]*$/.test(id)) { throw new Error(`Invalid identifier: ${id}`); } // escape embedded double quotes just in case - return Prisma.raw(`"${id.replace(/"/g, '""')}"`); + return `"${id.replace(/"/g, '""')}"`; +} + +export function sqlQuoteIdent(id: string) { + return Prisma.raw(sqlQuoteIdentToString(id)); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index affde77d4..942469559 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -2,7 +2,6 @@ import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { expect } from "vitest"; import { NiceResponse, it } from "../../../../helpers"; import { Auth, InternalApiKey, Project, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers"; -import { Result } from "@stackframe/stack-shared/dist/utils/results"; async function ensureAnonymousUsersAreStillExcluded(metricsResponse: NiceResponse) { for (let i = 0; i < 2; i++) { @@ -127,37 +126,33 @@ it("should exclude anonymous users from metrics", async ({ expect }) => { } // the event log is async, so let's give it some time to be written to the DB - const result = await Result.retry(async () => { - const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' }); - if (JSON.stringify(response.body) === JSON.stringify(beforeMetrics.body)) { - return Result.ok(response); + let result!: NiceResponse; + for (let i = 0; i < 5; i++) { + result = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' }); + if (JSON.stringify(result.body) === JSON.stringify(beforeMetrics.body)) { + break; } - return Result.error(response); - }, 5, { exponentialDelayBase: 200 }); - - if (result.status === "error") { - expect(beforeMetrics.body).toEqual(result.error); - throw new Error("Metrics response mismatch, should never be reached"); + await wait(2_000); } // Verify that total_users only counts the 1 regular user, not the anonymous ones - expect(result.data.body.total_users).toBe(1); + expect(result.body.total_users).toBe(1); // Verify anonymous users don't appear in recently_registered - expect(result.data.body.recently_registered.length).toBe(1); - expect(result.data.body.recently_registered.every((user: any) => !user.is_anonymous)).toBe(true); + expect(result.body.recently_registered.length).toBe(1); + expect(result.body.recently_registered.every((user: any) => !user.is_anonymous)).toBe(true); // Verify anonymous users don't appear in recently_active - expect(result.data.body.recently_active.every((user: any) => !user.is_anonymous)).toBe(true); + expect(result.body.recently_active.every((user: any) => !user.is_anonymous)).toBe(true); // Verify anonymous users aren't counted in daily_users - const lastDayUsers = result.data.body.daily_users[result.data.body.daily_users.length - 1]; + const lastDayUsers = result.body.daily_users[result.body.daily_users.length - 1]; expect(lastDayUsers.activity).toBe(1); // Verify users_by_country only includes regular users - expect(result.data.body.users_by_country["US"]).toBe(1); + expect(result.body.users_by_country["US"]).toBe(1); - await ensureAnonymousUsersAreStillExcluded(result.data); + await ensureAnonymousUsersAreStillExcluded(result); }); it("should handle anonymous users with activity correctly", async ({ expect }) => { diff --git a/docs/templates/others/convex.mdx b/docs/templates/others/convex.mdx index 95bf4f09f..28b88e8ec 100644 --- a/docs/templates/others/convex.mdx +++ b/docs/templates/others/convex.mdx @@ -50,8 +50,8 @@ export default { Then, update your Convex client to use Stack Auth: ```ts -convexClient.setAuth(stackServerApp.getConvexClientAuth()); // browser JS -convexReactClient.setAuth(stackServerApp.getConvexClientAuth()); // React +convexClient.setAuth(stackServerApp.getConvexClientAuth({})); // browser JS +convexReactClient.setAuth(stackServerApp.getConvexClientAuth({})); // React convexHttpClient.setAuth(stackServerApp.getAuthForConvexHttpClient({ tokenStore: requestObject })); // HTTP, see Stack Auth docs for more information on tokenStore ``` diff --git a/examples/convex/components/ConvexClientProvider.tsx b/examples/convex/components/ConvexClientProvider.tsx index d3a9a1ed9..8ab39fcc2 100644 --- a/examples/convex/components/ConvexClientProvider.tsx +++ b/examples/convex/components/ConvexClientProvider.tsx @@ -1,12 +1,12 @@ "use client"; -import { ReactNode } from "react"; -import { ConvexProvider, ConvexReactClient } from "convex/react"; import { stackClientApp } from "@/stack/client"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); convex.setAuth( - stackClientApp.getConvexClientAuth({ tokenStore: "nextjs-cookie" }) + stackClientApp.getConvexClientAuth({}) ); export default function ConvexClientProvider({ diff --git a/packages/init-stack/src/index.ts b/packages/init-stack/src/index.ts index ef43980bd..44dfbf8e6 100644 --- a/packages/init-stack/src/index.ts +++ b/packages/init-stack/src/index.ts @@ -237,6 +237,10 @@ async function main(): Promise { if (isNeon) packagesToInstall.push('@neondatabase/serverless'); await Steps.writeEnvVars(type); + const convexIntegration = await Steps.maybeInstallConvexIntegration({ packageJson: projectPackageJson, type }); + if (convexIntegration) { + nextSteps.push(...convexIntegration.instructions); + } if (type === "next") { const projectInfo = await Steps.getNextProjectInfo({ packageJson: projectPackageJson }); @@ -440,6 +444,10 @@ type StackAppFileResult = { fileName: string, } +type ConvexIntegrationResult = { + instructions: string[], +} + const Steps = { async getProject(): Promise<{ packageJson: PackageJson }> { let projectPath = await getProjectPath(); @@ -599,6 +607,65 @@ const Steps = { return false; }, + async maybeInstallConvexIntegration({ packageJson, type }: { packageJson: PackageJson, type: "js" | "next" | "react" }): Promise { + const hasConvexDependency = Boolean(packageJson.dependencies?.["convex"] || packageJson.devDependencies?.["convex"]); + if (!hasConvexDependency) { + return null; + } + + const projectPath = await getProjectPath(); + const convexDir = path.join(projectPath, "convex"); + if (!fs.existsSync(convexDir)) { + return null; + } + + const stackPackageName = await Steps.getStackPackageName(type); + const instructions: string[] = []; + + const authConfigPath = path.join(convexDir, "auth.config.ts"); + const desiredAuthConfig = createConvexAuthConfigContent({ stackPackageName, type }); + const existingAuthConfig = await readFile(authConfigPath); + if (!existingAuthConfig || (!existingAuthConfig.includes("getConvexProvidersConfig") && !existingAuthConfig.includes("@stackframe/"))) { + laterWriteFile(authConfigPath, desiredAuthConfig); + } + + const convexConfigPath = path.join(convexDir, "convex.config.ts"); + const existingConvexConfig = await readFile(convexConfigPath); + const desiredConvexConfig = createConvexIntegrationConvexConfigContent(stackPackageName); + let needsManualConvexConfig = false; + + if (!existingConvexConfig) { + laterWriteFile(convexConfigPath, desiredConvexConfig); + } else if (existingConvexConfig.includes("app.use(stackAuthComponent") && existingConvexConfig.includes("/convex.config") && existingConvexConfig.includes("stackframe")) { + // already integrated + } else { + const integratedContent = integrateConvexConfig(existingConvexConfig, stackPackageName); + if (integratedContent) { + laterWriteFile(convexConfigPath, integratedContent); + } else if (isSimpleConvexConfig(existingConvexConfig)) { + laterWriteFile(convexConfigPath, desiredConvexConfig); + } else { + needsManualConvexConfig = true; + } + } + + if (needsManualConvexConfig) { + instructions.push(`Update convex/convex.config.ts to import ${stackPackageName}/convex.config and call app.use(stackAuthComponent).`); + } + + const convexClientUpdateResult = await updateConvexClients({ projectPath, type }); + if (convexClientUpdateResult.skippedFiles.length > 0) { + instructions.push("Review your Convex client setup and call stackClientApp.getConvexClientAuth({}) or stackServerApp.getConvexClientAuth({}) manually where needed."); + } + + instructions.push( + "Set the Stack Auth environment variables in Convex (Deployment → Settings → Environment Variables).", + "Verify your Convex clients call stackClientApp.getConvexClientAuth({}) or stackServerApp.getConvexClientAuth({}) so they share authentication with Stack Auth." + ); + + return { instructions }; + }, + async dryUpdateNextLayoutFile({ appPath, defaultExtension }: { appPath: string, defaultExtension: string }): Promise<{ path: string, updatedContent: string, @@ -1131,6 +1198,403 @@ function laterWriteFileIfNotExists(fullPath: string, content: string): void { }); } +function createConvexAuthConfigContent(options: { stackPackageName: string, type: "js" | "next" | "react" }): string { + const envVarName = getPublicProjectEnvVarName(options.type); + return `import { getConvexProvidersConfig } from ${JSON.stringify(options.stackPackageName)}; + +export default { + providers: getConvexProvidersConfig({ + projectId: process.env.${envVarName}, + }), +}; +`; +} + +function createConvexIntegrationConvexConfigContent(stackPackageName: string): string { + const importPath = `${stackPackageName}/convex.config`; + return `import stackAuthComponent from ${JSON.stringify(importPath)}; +import { defineApp } from "convex/server"; + +const app = defineApp(); +app.use(stackAuthComponent); + +export default app; +`; +} + +function integrateConvexConfig(existingContent: string, stackPackageName: string): string | null { + if (!existingContent.includes("defineApp")) { + return null; + } + + const newline = existingContent.includes("\r\n") ? "\r\n" : "\n"; + const normalizedLines = existingContent.replace(/\r\n/g, "\n").split("\n"); + const importPath = `${stackPackageName}/convex.config`; + + const hasImport = normalizedLines.some((line) => line.includes(importPath)); + if (!hasImport) { + let insertIndex = 0; + while (insertIndex < normalizedLines.length && normalizedLines[insertIndex].trim() === "") { + insertIndex++; + } + while (insertIndex < normalizedLines.length && normalizedLines[insertIndex].trim().startsWith("import")) { + insertIndex++; + } + normalizedLines.splice(insertIndex, 0, `import stackAuthComponent from "${importPath}";`); + } + + let lastImportIndex = -1; + for (let i = 0; i < normalizedLines.length; i++) { + if (normalizedLines[i].trim().startsWith("import")) { + lastImportIndex = i; + continue; + } + if (normalizedLines[i].trim() === "") { + continue; + } + break; + } + if (lastImportIndex >= 0) { + const nextIndex = lastImportIndex + 1; + if (!normalizedLines[nextIndex] || normalizedLines[nextIndex].trim() !== "") { + normalizedLines.splice(nextIndex, 0, ""); + } + } + + const hasStackUse = normalizedLines.some((line) => line.includes("app.use(stackAuthComponent")); + if (!hasStackUse) { + const appLineIndex = normalizedLines.findIndex((line) => /const\s+app\s*=\s*defineApp/.test(line)); + if (appLineIndex === -1) { + return null; + } + const indent = normalizedLines[appLineIndex].match(/^\s*/)?.[0] ?? ""; + const insertIndexForUse = appLineIndex + 1; + normalizedLines.splice(insertIndexForUse, 0, `${indent}app.use(stackAuthComponent);`); + const nextLineIndex = insertIndexForUse + 1; + if (!normalizedLines[nextLineIndex] || normalizedLines[nextLineIndex].trim() !== "") { + normalizedLines.splice(nextLineIndex, 0, ""); + } + } + + let updated = normalizedLines.join(newline); + if (!updated.endsWith(newline)) { + updated += newline; + } + return updated; +} + +function isSimpleConvexConfig(content: string): boolean { + const normalized = content + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (normalized.length !== 3) { + return false; + } + const [line1, line2, line3] = normalized; + const importRegex = /^import\s+\{\s*defineApp\s*\}\s+from\s+['"]convex\/server['"];?$/; + const appRegex = /^const\s+app\s*=\s*defineApp\(\s*\);?$/; + const exportRegex = /^export\s+default\s+app;?$/; + return importRegex.test(line1) && appRegex.test(line2) && exportRegex.test(line3); +} + +function getPublicProjectEnvVarName(type: "js" | "next" | "react"): string { + if (type === "react") { + return "VITE_PUBLIC_STACK_PROJECT_ID"; + } + if (type === "next") { + return "NEXT_PUBLIC_STACK_PROJECT_ID"; + } + return "STACK_PROJECT_ID"; +} + +type ConvexClientUpdateResult = { + updatedFiles: string[], + skippedFiles: string[], +}; + +type AddSetAuthResult = { + updatedContent: string, + changed: boolean, + usedClientApp: boolean, + usedServerApp: boolean, + instantiationCount: number, + skippedHttpCount: number, +}; + +async function updateConvexClients({ projectPath, type }: { projectPath: string, type: "js" | "next" | "react" }): Promise { + const files = collectConvexClientCandidateFiles(projectPath); + const updatedFiles: string[] = []; + const skippedFiles: string[] = []; + + for (const filePath of files) { + const fileContent = await readFile(filePath); + if (!fileContent) continue; + if (!/new\s+Convex(?:React|Http)?Client\b/.test(fileContent)) continue; + + const addResult = addSetAuthToConvexClients(fileContent, type); + if (!addResult.changed) { + if (addResult.instantiationCount > 0 && addResult.skippedHttpCount > 0) { + skippedFiles.push(filePath); + } + continue; + } + + let finalContent = addResult.updatedContent; + if (addResult.usedClientApp) { + finalContent = await ensureStackAppImport(finalContent, filePath, "client"); + } + if (addResult.usedServerApp) { + finalContent = await ensureStackAppImport(finalContent, filePath, "server"); + } + + if (finalContent !== fileContent) { + laterWriteFile(filePath, finalContent); + updatedFiles.push(filePath); + } + } + + return { + updatedFiles, + skippedFiles, + }; +} + +type StackAppKind = "client" | "server"; + +async function ensureStackAppImport(content: string, filePath: string, kind: StackAppKind): Promise { + const identifier = kind === "client" ? "stackClientApp" : "stackServerApp"; + if (new RegExp(`import\\s+[^;]*\\b${identifier}\\b`).test(content)) { + return content; + } + + const stackBasePath = await getStackAppBasePath(kind); + const relativeImportPath = convertToModuleSpecifier(path.relative(path.dirname(filePath), stackBasePath)); + const newline = content.includes("\r\n") ? "\r\n" : "\n"; + + const lines = content.split(/\r?\n/); + const importLine = `import { ${identifier} } from "${relativeImportPath}";`; + + let insertIndex = 0; + while (insertIndex < lines.length) { + const line = lines[insertIndex]; + if (/^\s*['"]use (client|server)['"];?\s*$/.test(line)) { + insertIndex += 1; + continue; + } + if (/^\s*import\b/.test(line)) { + insertIndex += 1; + continue; + } + if (line.trim() === "") { + insertIndex += 1; + continue; + } + break; + } + + lines.splice(insertIndex, 0, importLine); + const nextLine = lines[insertIndex + 1]; + if (nextLine && nextLine.trim() !== "" && !/^\s*import\b/.test(nextLine)) { + lines.splice(insertIndex + 1, 0, ""); + } + + return lines.join(newline); +} + +function convertToModuleSpecifier(relativePath: string): string { + let specifier = relativePath.replace(/\\/g, "/"); + if (!specifier.startsWith(".")) { + specifier = "./" + specifier; + } + return specifier; +} + +async function getStackAppBasePath(kind: StackAppKind): Promise { + const srcPath = await Steps.guessSrcPath(); + return path.join(srcPath, "stack", kind); +} + +function addSetAuthToConvexClients(content: string, type: "js" | "next" | "react"): AddSetAuthResult { + const newline = content.includes("\r\n") ? "\r\n" : "\n"; + const instantiationRegex = /^[ \t]*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+(Convex(?:React|Http)?Client)\b([\s\S]*?);/gm; + const replacements: Array<{ start: number, end: number, text: string }> = []; + let instantiationCount = 0; + let skippedHttpCount = 0; + let usedClientApp = false; + let usedServerApp = false; + + let match: RegExpExecArray | null; + while ((match = instantiationRegex.exec(content)) !== null) { + instantiationCount += 1; + const fullMatch = match[0]; + const variableName = match[1]; + const className = match[2]; + + if (className === "ConvexHttpClient") { + skippedHttpCount += 1; + continue; + } + + const remainder = content.slice(match.index + fullMatch.length); + const setAuthRegex = new RegExp(`\\b${escapeRegExp(variableName)}\\s*\\.setAuth\\s*\\(`); + if (setAuthRegex.test(remainder)) { + continue; + } + + const indentation = fullMatch.match(/^[\t ]*/)?.[0] ?? ""; + const authCall = determineAuthCallExpression({ type, className, content }); + + if (authCall.identifier === "stackClientApp") { + usedClientApp = true; + } else { + usedServerApp = true; + } + + const replacementText = `${fullMatch}${newline}${indentation}${variableName}.setAuth(${authCall.expression});`; + replacements.push({ + start: match.index, + end: match.index + fullMatch.length, + text: replacementText, + }); + } + + if (replacements.length === 0) { + return { + updatedContent: content, + changed: false, + usedClientApp, + usedServerApp, + instantiationCount, + skippedHttpCount, + }; + } + + let updatedContent = content; + for (let i = replacements.length - 1; i >= 0; i--) { + const replacement = replacements[i]; + updatedContent = `${updatedContent.slice(0, replacement.start)}${replacement.text}${updatedContent.slice(replacement.end)}`; + } + + return { + updatedContent, + changed: true, + usedClientApp, + usedServerApp, + instantiationCount, + skippedHttpCount, + }; +} + +function determineAuthCallExpression({ type, className, content }: { type: "js" | "next" | "react", className: string, content: string }): { expression: string, identifier: "stackClientApp" | "stackServerApp" } { + const hasClientAppReference = /\bstackClientApp\b/.test(content); + const hasServerAppReference = /\bstackServerApp\b/.test(content); + + if (type === "js") { + return { expression: "stackServerApp.getConvexClientAuth({})", identifier: "stackServerApp" }; + } + + if (hasClientAppReference) { + return { expression: getClientAuthCall(type), identifier: "stackClientApp" }; + } + if (hasServerAppReference && className !== "ConvexReactClient") { + return { expression: "stackServerApp.getConvexClientAuth({})", identifier: "stackServerApp" }; + } + + return { expression: getClientAuthCall(type), identifier: "stackClientApp" }; +} + +function getClientAuthCall(type: "js" | "next" | "react"): string { + return "stackClientApp.getConvexClientAuth({})"; +} + +function collectConvexClientCandidateFiles(projectPath: string): string[] { + const roots = getConvexSearchRoots(projectPath); + const files = new Set(); + const visited = new Set(); + + for (const root of roots) { + walkDirectory(root, files, visited); + } + + return Array.from(files); +} + +function getConvexSearchRoots(projectPath: string): string[] { + const candidateDirs = ["convex", "src", "app", "components"]; + const existing = candidateDirs + .map((dir) => path.join(projectPath, dir)) + .filter((dirPath) => { + try { + return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory(); + } catch { + return false; + } + }); + if (existing.length > 0) { + return existing; + } + return [projectPath]; +} + +const directorySkipList = new Set([ + "node_modules", + ".git", + ".next", + ".turbo", + ".output", + ".vercel", + "dist", + "build", + "coverage", + ".cache", + ".storybook", + "storybook-static", +]); + +function walkDirectory(currentDir: string, files: Set, visited: Set): void { + const realPath = (() => { + try { + return fs.realpathSync(currentDir); + } catch { + return currentDir; + } + })(); + + if (visited.has(realPath)) return; + visited.add(realPath); + + let dirEntries: fs.Dirent[]; + try { + dirEntries = fs.readdirSync(realPath, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of dirEntries) { + const entryName = entry.name; + if (entry.isDirectory()) { + if (directorySkipList.has(entryName)) continue; + if (entryName.startsWith(".") || entryName.startsWith("_")) continue; + walkDirectory(path.join(realPath, entryName), files, visited); + continue; + } + if (!entry.isFile()) continue; + if (entryName.endsWith(".d.ts")) continue; + if (!hasJsLikeExtension(entryName)) continue; + files.add(path.join(realPath, entryName)); + } +} + +function hasJsLikeExtension(fileName: string): boolean { + return jsLikeFileExtensions.some((ext) => fileName.endsWith(`.${ext}`)); +} + +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function throwErr(message: string): never { throw new Error(message); } diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 41329b28d..12259ec19 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -1,11 +1,11 @@ import { WebAuthnError, startAuthentication, startRegistration } from "@simplewebauthn/browser"; import { KnownErrors, StackClientInterface } from "@stackframe/stack-shared"; -import type { CustomerProductsListResponse } from "@stackframe/stack-shared/dist/interface/crud/products"; import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels"; import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user"; import { ItemCrud } from "@stackframe/stack-shared/dist/interface/crud/items"; import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences"; import { OAuthProviderCrud } from "@stackframe/stack-shared/dist/interface/crud/oauth-providers"; +import type { CustomerProductsListResponse } from "@stackframe/stack-shared/dist/interface/crud/products"; import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateOutputSchema, userApiKeysCreateOutputSchema } from "@stackframe/stack-shared/dist/interface/crud/project-api-keys"; import { ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; import { ClientProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; @@ -1749,7 +1749,7 @@ export class _StackClientAppImplIncomplete Promise { return async (args: { forceRefreshToken: boolean }) => { - const session = await this._getSession(options.tokenStore); + const session = await this._getSession(options.tokenStore ?? this._tokenStoreInit); if (!args.forceRefreshToken) { const tokens = await session.getOrFetchLikelyValidTokens(20_000); return tokens?.accessToken.token ?? null; diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 418785407..6e8728c30 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -56,7 +56,7 @@ export type StackClientApp, - getConvexClientAuth(options: { tokenStore: TokenStoreInit }): (args: { forceRefreshToken: boolean }) => Promise, + getConvexClientAuth(options: HasTokenStore extends false ? { tokenStore: TokenStoreInit } : { tokenStore?: TokenStoreInit }): (args: { forceRefreshToken: boolean }) => Promise, getConvexHttpClientAuth(options: { tokenStore: TokenStoreInit }): Promise, // IF_PLATFORM react-like