diff --git a/package.json b/package.json index e343b0f6..564e0c92 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@clack/prompts": "1.0.1", "@napi-rs/keyring": "^1.2.0", "@workos-inc/node": "^8.7.0", - "@workos/skills": "0.2.2", + "@workos/skills": "0.2.4", "chalk": "^5.6.2", "diff": "^8.0.3", "fast-glob": "^3.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a158695e..5d682172 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^8.7.0 version: 8.7.0 '@workos/skills': - specifier: 0.2.2 - version: 0.2.2 + specifier: 0.2.4 + version: 0.2.4 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -1601,8 +1601,8 @@ packages: resolution: {integrity: sha512-43HfXSR2Ez7M4ixpebuYVZzZf3gauh5jvv9lYnePg/x0XZMN2hjpEV3FD1LQX1vfMbqQ5gON3DN+/gH2rITm3A==} engines: {node: '>=20.15.0'} - '@workos/skills@0.2.2': - resolution: {integrity: sha512-jO4HuI4seyAjoBwfl2bTQe5FXE5ir1nyJ5+RkYG4yhpIvsIThBJkAoYJwdhRhFynzJAYuQIb7v6E7a2zBto7ww==} + '@workos/skills@0.2.4': + resolution: {integrity: sha512-Ku/8aD6zRwA7M4jREp/l1pUHOGCmDMBudlJAUfpkfZqFdDatxWJy+CQmYiIlMKt3IP4/GURe1rA72HpAcEkcaw==} abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} @@ -3833,7 +3833,7 @@ snapshots: iron-webcrypto: 2.0.0 jose: 6.1.3 - '@workos/skills@0.2.2': + '@workos/skills@0.2.4': dependencies: yaml: 2.8.2 diff --git a/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts b/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts new file mode 100644 index 00000000..3de42e00 --- /dev/null +++ b/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { findUnsafeGetSignInUrlUsage } from '../nextjs.grader.js'; + +describe('findUnsafeGetSignInUrlUsage', () => { + let workDir: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'grader-test-')); + await mkdir(join(workDir, 'app'), { recursive: true }); + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it('fails when getSignInUrl() is called in app/page.tsx', async () => { + await writeFile( + join(workDir, 'app/page.tsx'), + ` +import { getSignInUrl } from '@workos-inc/authkit-nextjs'; +export default async function Page() { + const url = await getSignInUrl(); + return Sign in; +} +`, + ); + const result = await findUnsafeGetSignInUrlUsage(workDir); + expect(result).not.toBeNull(); + expect(result!.file).toBe('app/page.tsx'); + }); + + it('fails when getSignInUrl() is in a shared component without directive', async () => { + await mkdir(join(workDir, 'app/components'), { recursive: true }); + await writeFile( + join(workDir, 'app/components/nav-auth.tsx'), + ` +import { getSignInUrl } from '@workos-inc/authkit-nextjs'; +export default async function NavAuth() { + const url = await getSignInUrl(); + return Sign in; +} +`, + ); + const result = await findUnsafeGetSignInUrlUsage(workDir); + expect(result).not.toBeNull(); + expect(result!.file).toContain('nav-auth.tsx'); + }); + + it('passes when getSignInUrl() is in a use client component', async () => { + await writeFile( + join(workDir, 'app/page.tsx'), + ` +'use client'; +import { getSignInUrl } from '@workos-inc/authkit-nextjs'; +export default function Page() { + const handleClick = async () => { const url = await getSignInUrl(); window.location.href = url; }; + return ; +} +`, + ); + const result = await findUnsafeGetSignInUrlUsage(workDir); + expect(result).toBeNull(); + }); + + it('passes when getSignInUrl() is in a top-level use server file', async () => { + await mkdir(join(workDir, 'app/actions'), { recursive: true }); + await writeFile( + join(workDir, 'app/actions/auth.tsx'), + ` +'use server'; +import { getSignInUrl } from '@workos-inc/authkit-nextjs'; +export async function getUrl() { return getSignInUrl(); } +`, + ); + const result = await findUnsafeGetSignInUrlUsage(workDir); + expect(result).toBeNull(); + }); + + it('fails when use server is inline, not top-level', async () => { + await writeFile( + join(workDir, 'app/page.tsx'), + ` +import { getSignInUrl } from '@workos-inc/authkit-nextjs'; +export default async function Page() { + const url = await getSignInUrl(); + async function logout() { + 'use server'; + // server action + } + return Sign in; +} +`, + ); + const result = await findUnsafeGetSignInUrlUsage(workDir); + expect(result).not.toBeNull(); + }); + + it('passes when no files contain getSignInUrl()', async () => { + await writeFile( + join(workDir, 'app/page.tsx'), + ` +export default function Page() { + return

Home

; +} +`, + ); + const result = await findUnsafeGetSignInUrlUsage(workDir); + expect(result).toBeNull(); + }); + + it('ignores mere mention of getSignInUrl without invocation', async () => { + await writeFile( + join(workDir, 'app/page.tsx'), + ` +// Do not use getSignInUrl in server components +export default function Page() { return

Home

; } +`, + ); + const result = await findUnsafeGetSignInUrlUsage(workDir); + expect(result).toBeNull(); + }); + + it('ignores commented-out getSignInUrl() calls', async () => { + await writeFile( + join(workDir, 'app/page.tsx'), + ` +// don't call getSignInUrl() here +/* const url = await getSignInUrl(); */ +export default function Page() { return

Home

; } +`, + ); + const result = await findUnsafeGetSignInUrlUsage(workDir); + expect(result).toBeNull(); + }); +}); diff --git a/tests/evals/graders/nextjs.grader.ts b/tests/evals/graders/nextjs.grader.ts index 16f40177..6caf7374 100644 --- a/tests/evals/graders/nextjs.grader.ts +++ b/tests/evals/graders/nextjs.grader.ts @@ -1,12 +1,63 @@ +import fg from 'fast-glob'; +import { readFile } from 'node:fs/promises'; +import { relative } from 'node:path'; import { FileGrader } from './file-grader.js'; import { BuildGrader } from './build-grader.js'; import type { Grader, GradeResult, GradeCheck } from '../types.js'; +/** + * Module prologue directive check. + * Only matches 'use client' / 'use server' when they appear as the + * first statement in the file (ignoring leading comments and whitespace). + * Does NOT match inline 'use server' inside function bodies. + */ +export function hasTopLevelDirective(content: string, directive: string): boolean { + // Strip leading whitespace, single-line comments, and multi-line comments + const stripped = content.replace(/^\s*(\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/g, ''); + // Check if the file starts with the directive (single or double quotes, with semicolon optional) + return stripped.startsWith(`'${directive}'`) || stripped.startsWith(`"${directive}"`); +} + +const INVOCATION_PATTERN = /\bgetSignInUrl\s*\(/; + +/** + * Strip single-line (//) and multi-line comments from source code + * so the invocation regex doesn't match commented-out calls. + */ +function stripComments(content: string): string { + return content.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, ''); +} + +export async function findUnsafeGetSignInUrlUsage(workDir: string): Promise<{ file: string } | null> { + const files = await fg('{app,src/app}/**/*.tsx', { + cwd: workDir, + ignore: ['**/callback/**', '**/node_modules/**'], + absolute: true, + }); + + for (const file of files) { + const content = await readFile(file, 'utf-8'); + const code = stripComments(content); + + if ( + INVOCATION_PATTERN.test(code) && + !hasTopLevelDirective(content, 'use client') && + !hasTopLevelDirective(content, 'use server') + ) { + return { file: relative(workDir, file) }; + } + } + + return null; +} + export class NextjsGrader implements Grader { private fileGrader: FileGrader; private buildGrader: BuildGrader; + private workDir: string; constructor(workDir: string) { + this.workDir = workDir; this.fileGrader = new FileGrader(workDir); this.buildGrader = new BuildGrader(workDir); } @@ -91,6 +142,16 @@ export class NextjsGrader implements Grader { ); checks.push(authKitProviderCheck); + // Check for getSignInUrl() in server components (no top-level directive) + const unsafeUsage = await findUnsafeGetSignInUrlUsage(this.workDir); + checks.push({ + name: 'No getSignInUrl in Server Components', + passed: unsafeUsage === null, + message: unsafeUsage + ? `${unsafeUsage.file} calls getSignInUrl() without a top-level 'use client' or 'use server' directive — will throw in Next.js 15+` + : 'No unsafe getSignInUrl usage in Server Components', + }); + // Check build succeeds checks.push(await this.buildGrader.checkBuild());