From aa875e32946bb979932f3c9bce3e36bd2a45834d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 7 Jul 2026 15:55:14 -0500 Subject: [PATCH] fix: detect Vite port for TanStack Start and validate redirect URI port detectPort now reads vite.config.{ts,js,mjs} for tanstack-start (modern @tanstack/react-start is Vite-based), falling back to legacy Vinxi app.config.ts, and the shared react/react-router/vanilla-js Vite loop is extracted into a parseViteConfigPortFromDir helper. Redirect-URI validators (Next.js, React Router, TanStack Start) now compare the URI's effective port to the detected dev-server port for local hosts and push an error-severity issue on mismatch, flipping validation to failed instead of silently passing. Route-mismatch hints use the redirect URI's real origin instead of a hardcoded localhost:3000. Fixes the Akshay friction-log trust failure where a Lovable TanStack Start app on port 8080 was configured against localhost:3000 yet certified as "Validation passed". --- src/doctor/checks/framework.spec.ts | 8 ++ src/lib/port-detection.spec.ts | 73 +++++++++++ src/lib/port-detection.ts | 31 +++-- src/lib/validation/validator.spec.ts | 174 ++++++++++++++++++++++++++- src/lib/validation/validator.ts | 41 ++++++- 5 files changed, 310 insertions(+), 17 deletions(-) diff --git a/src/doctor/checks/framework.spec.ts b/src/doctor/checks/framework.spec.ts index 6ac246be..2d5fda05 100644 --- a/src/doctor/checks/framework.spec.ts +++ b/src/doctor/checks/framework.spec.ts @@ -89,6 +89,14 @@ describe('checkFramework - new frameworks', () => { expect(result.name).toBe('SvelteKit'); }); + it('reports the vite.config port for a modern TanStack Start app (detectPort fan-out)', async () => { + await writeFile(join(dir, 'package.json'), makePackageJson({ '@tanstack/react-start': '^1.0.0' })); + await writeFile(join(dir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n'); + const result = await checkFramework({ installDir: dir }); + expect(result.name).toBe('TanStack Start'); + expect(result.detectedPort).toBe(8080); + }); + it('returns null for no frameworks', async () => { await writeFile(join(dir, 'package.json'), makePackageJson({})); const result = await checkFramework({ installDir: dir }); diff --git a/src/lib/port-detection.spec.ts b/src/lib/port-detection.spec.ts index 35591576..0d8628ce 100644 --- a/src/lib/port-detection.spec.ts +++ b/src/lib/port-detection.spec.ts @@ -155,3 +155,76 @@ describe('port-detection — ruby/rails puma', () => { expect(detectPort('ruby', dir)).toBe(4000); }); }); + +describe('port-detection — tanstack-start config', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-tanstack-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('parses port from vite.config.ts (modern @tanstack/react-start)', async () => { + await writeFile( + join(dir, 'vite.config.ts'), + "import { defineConfig } from 'vite'\nexport default defineConfig({ server: { host: '::', port: 8080 }, plugins: [] })\n", + ); + expect(detectPort('tanstack-start', dir)).toBe(8080); + }); + + it('falls back to legacy app.config.ts (Vinxi) when no vite config', async () => { + await writeFile(join(dir, 'app.config.ts'), 'export default { server: { port: 4200 } }\n'); + expect(detectPort('tanstack-start', dir)).toBe(4200); + }); + + it('prefers vite.config.ts over app.config.ts when both present', async () => { + await writeFile( + join(dir, 'vite.config.ts'), + "import { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 8080 } })\n", + ); + await writeFile(join(dir, 'app.config.ts'), 'export default { server: { port: 4200 } }\n'); + expect(detectPort('tanstack-start', dir)).toBe(8080); + }); + + it.each(['vite.config.js', 'vite.config.mjs'])('parses port from %s', async (fileName) => { + await writeFile(join(dir, fileName), 'export default { server: { port: 5555 } }\n'); + expect(detectPort('tanstack-start', dir)).toBe(5555); + }); + + it('falls back to default 3000 when no config file present', () => { + expect(detectPort('tanstack-start', dir)).toBe(3000); + }); +}); + +describe('port-detection — vite frameworks', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'port-vite-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it.each(['react', 'react-router', 'vanilla-js'] as const)( + '%s parses port from vite.config.ts', + async (framework) => { + await writeFile( + join(dir, 'vite.config.ts'), + "import { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 4200 } })\n", + ); + expect(detectPort(framework, dir)).toBe(4200); + }, + ); + + it.each(['react', 'react-router', 'vanilla-js'] as const)( + '%s falls back to default 5173 when no vite config present', + (framework) => { + expect(detectPort(framework, dir)).toBe(5173); + }, + ); +}); diff --git a/src/lib/port-detection.ts b/src/lib/port-detection.ts index f0a8cb74..420de6ad 100644 --- a/src/lib/port-detection.ts +++ b/src/lib/port-detection.ts @@ -52,6 +52,20 @@ function parseViteConfigPort(configPath: string): number | null { return null; } +/** + * Try vite.config.{ts,js,mjs} in installDir for a server.port value. + * NOTE: parseViteConfigPort uses a greedy first-match /port\s*:\s*.../ regex, + * so an unrelated `port:` (e.g. hmr.port) appearing before server.port wins. + * Pre-existing behavior for all Vite frameworks — not a regression here. + */ +function parseViteConfigPortFromDir(installDir: string): number | null { + for (const p of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) { + const port = parseViteConfigPort(join(installDir, p)); + if (port) return port; + } + return null; +} + /** * Parse port from Next.js package.json scripts. * Next.js uses: "dev": "next dev -p 4000" or --port 4000 @@ -206,24 +220,17 @@ export function detectPort(integration: Integration, installDir: string): number break; case 'tanstack-start': - detectedPort = parseTanStackPort(installDir); + // Modern @tanstack/react-start is Vite-based (port in vite.config.ts); + // legacy Vinxi projects use app.config.ts. Try Vite first. + detectedPort = parseViteConfigPortFromDir(installDir) ?? parseTanStackPort(installDir); break; case 'react': case 'react-router': - case 'vanilla-js': { + case 'vanilla-js': // Vite-based frameworks - const viteConfigs = [ - join(installDir, 'vite.config.ts'), - join(installDir, 'vite.config.js'), - join(installDir, 'vite.config.mjs'), - ]; - for (const configPath of viteConfigs) { - detectedPort = parseViteConfigPort(configPath); - if (detectedPort) break; - } + detectedPort = parseViteConfigPortFromDir(installDir); break; - } case 'dotnet': detectedPort = parseDotnetPort(installDir); diff --git a/src/lib/validation/validator.spec.ts b/src/lib/validation/validator.spec.ts index 8aae97ca..7c1fef7b 100644 --- a/src/lib/validation/validator.spec.ts +++ b/src/lib/validation/validator.spec.ts @@ -373,7 +373,7 @@ describe('validateInstallation', () => { // Redirect URI says /auth/callback but route is at /callback writeFileSync( join(testDir, '.env.local'), - 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:5173/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', ); // Route exists at DIFFERENT path (dot notation) mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); @@ -390,7 +390,7 @@ describe('validateInstallation', () => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); writeFileSync( join(testDir, '.env.local'), - 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + 'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:5173/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', ); // Route at correct path using dot notation: auth.callback.tsx mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); @@ -819,4 +819,174 @@ describe('validateInstallation', () => { expect(envIssue?.hint).toContain('VITE_WORKOS_CLIENT_ID'); }); }); + + describe('redirect URI port validation', () => { + const portError = (result: { issues: { severity: string; message: string }[] }) => + result.issues.find((i) => i.severity === 'error' && /port/i.test(i.message)); + + it('flags TanStack redirect URI port that mismatches the vite.config port (friction scenario)', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }), + ); + // dev server runs on 8080 (Vite), but the redirect URI points at :3000 + writeFileSync(join(testDir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n'); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'), + 'export default function Callback() {}', + ); + + const result = await validateInstallation('tanstack-start', testDir, { runBuild: false }); + + expect(result.passed).toBe(false); + expect(portError(result)).toBeDefined(); + }); + + it('does not flag a port issue when TanStack redirect URI port matches the vite.config port', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }), + ); + writeFileSync(join(testDir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n'); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:8080/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'), + 'export default function Callback() {}', + ); + + const result = await validateInstallation('tanstack-start', testDir, { runBuild: false }); + + expect(portError(result)).toBeUndefined(); + const mismatchIssue = result.issues.find((i) => i.message.includes('no matching route file')); + expect(mismatchIssue).toBeUndefined(); + }); + + it('flags React Router redirect URI port against the default 5173 (helper in isolation)', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); + // No vite.config → detected port is the RR default 5173; env points at :3000 + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); + writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}'); + + const result = await validateInstallation('react-router', testDir, { runBuild: false }); + + expect(result.passed).toBe(false); + expect(portError(result)).toBeDefined(); + }); + + it('flags Next.js redirect URI port against a -p dev-script port', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + dependencies: { '@workos-inc/authkit-nextjs': '^1.0.0' }, + scripts: { dev: 'next dev -p 4000' }, + }), + ); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'api', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'api', 'auth', 'callback', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs';", + ); + writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};'); + writeFileSync(join(testDir, 'app', 'layout.tsx'), ''); + + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + + expect(portError(result)).toBeDefined(); + }); + + it('skips the port check for non-local (production) hosts', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }), + ); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=https://app.example.com/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'), + 'export default function Callback() {}', + ); + + const result = await validateInstallation('tanstack-start', testDir, { runBuild: false }); + + expect(portError(result)).toBeUndefined(); + }); + + it('normalizes a missing port to the protocol default (80) before comparing', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); + writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}'); + + const result = await validateInstallation('react-router', testDir, { runBuild: false }); + + expect(portError(result)).toBeDefined(); + }); + + it('recognizes the IPv6 localhost form [::1]', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } })); + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://[::1]:3000/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + mkdirSync(join(testDir, 'app', 'routes'), { recursive: true }); + writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}'); + + const result = await validateInstallation('react-router', testDir, { runBuild: false }); + + expect(portError(result)).toBeDefined(); + }); + + it('route-mismatch hint uses the redirect URI real origin, not a hardcoded localhost:3000', async () => { + writeFileSync( + join(testDir, 'package.json'), + JSON.stringify({ + dependencies: { '@workos-inc/authkit-nextjs': '^1.0.0' }, + scripts: { dev: 'next dev -p 8080' }, + }), + ); + // detected port (8080) matches the env port so no port-mismatch noise + writeFileSync( + join(testDir, '.env.local'), + 'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:8080/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n', + ); + // route lives at a DIFFERENT path → triggers the mismatch hint + mkdirSync(join(testDir, 'app', 'api', 'auth', 'callback'), { recursive: true }); + writeFileSync( + join(testDir, 'app', 'api', 'auth', 'callback', 'route.ts'), + "import { handleAuth } from '@workos-inc/authkit-nextjs';", + ); + writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};'); + writeFileSync(join(testDir, 'app', 'layout.tsx'), ''); + + const result = await validateInstallation('nextjs', testDir, { runBuild: false }); + + const mismatchIssue = result.issues.find((i) => i.message.includes('no matching route file')); + expect(mismatchIssue).toBeDefined(); + expect(mismatchIssue?.hint).toContain('http://localhost:8080'); + expect(mismatchIssue?.hint).not.toContain('localhost:3000'); + }); + }); }); diff --git a/src/lib/validation/validator.ts b/src/lib/validation/validator.ts index 18146b7a..111b5340 100644 --- a/src/lib/validation/validator.ts +++ b/src/lib/validation/validator.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import fg from 'fast-glob'; import type { ValidationResult, ValidationRules, ValidationIssue } from './types.js'; import { runBuildValidation } from './build-validator.js'; +import { detectPort } from '../port-detection.js'; export interface ValidateOptions { variant?: string; @@ -250,6 +251,37 @@ export async function validateFrameworkSpecific(framework: string, projectDir: s return issues; } +/** + * Compares the redirect URI's effective port to the detected dev server port, + * for local dev hosts only, and records an error-severity issue on mismatch. + * Skips production URIs and placeholder hosts like http://test. + */ +function validateRedirectUriPort( + url: URL, + framework: string, + projectDir: string, + issues: ValidationIssue[], +): void { + // Only enforce for local dev hosts; skip production URIs and placeholder + // hosts like http://test. Node parses IPv6 hostnames with brackets: [::1]. + const localHosts = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + if (!localHosts.has(url.hostname)) return; + + const effectivePort = url.port || (url.protocol === 'https:' ? '443' : '80'); + const detected = detectPort(framework, projectDir); + + if (Number(effectivePort) !== detected) { + issues.push({ + type: 'env', + severity: 'error', + message: `Redirect URI port :${effectivePort} does not match the detected dev server port :${detected}`, + hint: + `Update the redirect URI to use port :${detected} in .env.local and the WorkOS dashboard ` + + `(redirect URI, CORS origin, homepage URL), or change your dev server to run on port ${effectivePort}.`, + }); + } +} + /** * Validates that the Next.js redirect URI matches an existing callback route. * @@ -277,6 +309,7 @@ async function validateNextjsRedirectUri(projectDir: string, issues: ValidationI try { const url = new URL(redirectUri); callbackPath = url.pathname; + validateRedirectUriPort(url, 'nextjs', projectDir, issues); } catch { issues.push({ type: 'env', @@ -319,7 +352,7 @@ async function validateNextjsRedirectUri(projectDir: string, issues: ValidationI const actualPath = '/' + existingRoutes[0].replace(/^(src\/)?app\//, '').replace(/\/route\.(ts|tsx|js|jsx)$/, ''); hint = `Found callback route at ${existingRoutes[0]} but redirect URI points to ${callbackPath}. Either:\n` + - ` 1. Change NEXT_PUBLIC_WORKOS_REDIRECT_URI to http://localhost:3000${actualPath}\n` + + ` 1. Change NEXT_PUBLIC_WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + ` 2. Move the route to app/${routePath}/route.ts`; } @@ -360,6 +393,7 @@ async function validateReactRouterRedirectUri(projectDir: string, issues: Valida try { const url = new URL(redirectUri); callbackPath = url.pathname; + validateRedirectUriPort(url, 'react-router', projectDir, issues); } catch { issues.push({ type: 'env', @@ -418,7 +452,7 @@ async function validateReactRouterRedirectUri(projectDir: string, issues: Valida .replace(/\./g, '/'); hint = `Found callback route at ${actualFile} but redirect URI points to ${callbackPath}. Either:\n` + - ` 1. Change WORKOS_REDIRECT_URI to http://localhost:3000${actualPath}\n` + + ` 1. Change WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + ` 2. Move the route to app/routes/${dotPath}.tsx`; } @@ -458,6 +492,7 @@ async function validateTanstackStartRedirectUri(projectDir: string, issues: Vali try { const url = new URL(redirectUri); callbackPath = url.pathname; + validateRedirectUriPort(url, 'tanstack-start', projectDir, issues); } catch { issues.push({ type: 'env', @@ -500,7 +535,7 @@ async function validateTanstackStartRedirectUri(projectDir: string, issues: Vali .replace(/\/index$/, ''); hint = `Found callback route at ${actualFile} but redirect URI points to ${callbackPath}. Either:\n` + - ` 1. Change WORKOS_REDIRECT_URI to http://localhost:3000${actualPath}\n` + + ` 1. Change WORKOS_REDIRECT_URI to ${new URL(redirectUri).origin}${actualPath}\n` + ` 2. Move the route to app/routes/${routePath}.tsx`; }