Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

138 changes: 138 additions & 0 deletions tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <a href={url}>Sign in</a>;
}
`,
);
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 <a href={url}>Sign in</a>;
}
`,
);
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 <button onClick={handleClick}>Sign in</button>;
}
`,
);
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 <a href={url}>Sign in</a>;
}
`,
);
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 <h1>Home</h1>;
}
`,
);
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 <h1>Home</h1>; }
`,
);
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 <h1>Home</h1>; }
`,
);
const result = await findUnsafeGetSignInUrlUsage(workDir);
expect(result).toBeNull();
});
});
61 changes: 61 additions & 0 deletions tests/evals/graders/nextjs.grader.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down Expand Up @@ -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());

Expand Down
Loading