From 64245474c029f1330d71e103efc915219aa0dcbd Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 30 Mar 2026 11:43:51 -0500 Subject: [PATCH 1/6] fix: add NextjsGrader check for getSignInUrl in Server Components - Add exported findUnsafeGetSignInUrlUsage() helper that scans .tsx files for getSignInUrl() invocations without top-level 'use client' or 'use server' directives - Add hasTopLevelDirective() with module prologue detection (strips comments/whitespace, ignores inline 'use server' in function bodies) - Wire check into grade() after AuthKitProvider check - Add 7 unit tests covering: server page, shared component, client component, top-level server action, inline server action trap, no usage, and comment mention edge case Addresses Alexander Southgate's friction log where the agent generated nav-auth.tsx as a Server Component calling getSignInUrl(). --- .../nextjs-server-component-safety.spec.ts | 101 ++++++++++++++++++ tests/evals/graders/nextjs.grader.ts | 56 ++++++++++ 2 files changed, 157 insertions(+) create mode 100644 tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts 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..f3d40bba --- /dev/null +++ b/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts @@ -0,0 +1,101 @@ +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'; +// client component — safe +`); + 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(); + }); +}); diff --git a/tests/evals/graders/nextjs.grader.ts b/tests/evals/graders/nextjs.grader.ts index 16f40177..c4ce14ee 100644 --- a/tests/evals/graders/nextjs.grader.ts +++ b/tests/evals/graders/nextjs.grader.ts @@ -1,12 +1,58 @@ +import fg from 'fast-glob'; +import { readFile } from 'node:fs/promises'; 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*\(/; + +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'); + + if ( + INVOCATION_PATTERN.test(content) && + !hasTopLevelDirective(content, 'use client') && + !hasTopLevelDirective(content, 'use server') + ) { + return { file: file.replace(workDir + '/', '') }; + } + } + + 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 +137,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()); From 745ac040c6caaedb65f81f935414833a713ee605 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 30 Mar 2026 11:57:17 -0500 Subject: [PATCH 2/6] fix: strip comments before checking getSignInUrl invocations The invocation regex matched getSignInUrl() inside comments, causing false positives on files with commented-out examples or TODOs like "// don't call getSignInUrl() here". Strip single-line and multi-line comments before testing the regex. Add test case for this edge case. --- .../__tests__/nextjs-server-component-safety.spec.ts | 10 ++++++++++ tests/evals/graders/nextjs.grader.ts | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts b/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts index f3d40bba..b2eb6b7d 100644 --- a/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts +++ b/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts @@ -94,6 +94,16 @@ export default function Page() { 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 c4ce14ee..1a2fa8e3 100644 --- a/tests/evals/graders/nextjs.grader.ts +++ b/tests/evals/graders/nextjs.grader.ts @@ -22,6 +22,14 @@ export function hasTopLevelDirective(content: string, directive: string): boolea 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]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); +} + export async function findUnsafeGetSignInUrlUsage( workDir: string, ): Promise<{ file: string } | null> { @@ -33,9 +41,10 @@ export async function findUnsafeGetSignInUrlUsage( for (const file of files) { const content = await readFile(file, 'utf-8'); + const code = stripComments(content); if ( - INVOCATION_PATTERN.test(content) && + INVOCATION_PATTERN.test(code) && !hasTopLevelDirective(content, 'use client') && !hasTopLevelDirective(content, 'use server') ) { From 18610334dcd42907cb35cccf9b2aa5ce8a51b17d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 30 Mar 2026 14:01:26 -0500 Subject: [PATCH 3/6] test: strengthen use client test with real getSignInUrl() invocation The test fixture previously only imported getSignInUrl without calling it. Now includes an actual getSignInUrl() call inside an onClick handler to properly exercise the directive detection logic. --- .../graders/__tests__/nextjs-server-component-safety.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts b/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts index b2eb6b7d..f99e3d65 100644 --- a/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts +++ b/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts @@ -47,7 +47,10 @@ export default async function NavAuth() { await writeFile(join(workDir, 'app/page.tsx'), ` 'use client'; import { getSignInUrl } from '@workos-inc/authkit-nextjs'; -// client component — safe +export default function Page() { + const handleClick = async () => { const url = await getSignInUrl(); window.location.href = url; }; + return ; +} `); const result = await findUnsafeGetSignInUrlUsage(workDir); expect(result).toBeNull(); From ae1e45470215b1c384c8230113c2e5d28f2c46e4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 30 Mar 2026 14:03:25 -0500 Subject: [PATCH 4/6] chore: formatting --- .../nextjs-server-component-safety.spec.ts | 56 +++++++++++++------ tests/evals/graders/nextjs.grader.ts | 9 +-- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts b/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts index f99e3d65..3de42e00 100644 --- a/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts +++ b/tests/evals/graders/__tests__/nextjs-server-component-safety.spec.ts @@ -17,13 +17,16 @@ describe('findUnsafeGetSignInUrlUsage', () => { }); it('fails when getSignInUrl() is called in app/page.tsx', async () => { - await writeFile(join(workDir, 'app/page.tsx'), ` + 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'); @@ -31,44 +34,55 @@ export default async function Page() { 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'), ` + 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'), ` + 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'), ` + 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'), ` + await writeFile( + join(workDir, 'app/page.tsx'), + ` import { getSignInUrl } from '@workos-inc/authkit-nextjs'; export default async function Page() { const url = await getSignInUrl(); @@ -78,36 +92,46 @@ export default async function Page() { } 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'), ` + 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'), ` + 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'), ` + 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 1a2fa8e3..c350ea0f 100644 --- a/tests/evals/graders/nextjs.grader.ts +++ b/tests/evals/graders/nextjs.grader.ts @@ -14,10 +14,7 @@ export function hasTopLevelDirective(content: string, directive: string): boolea // 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}"`) - ); + return stripped.startsWith(`'${directive}'`) || stripped.startsWith(`"${directive}"`); } const INVOCATION_PATTERN = /\bgetSignInUrl\s*\(/; @@ -30,9 +27,7 @@ function stripComments(content: string): string { return content.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); } -export async function findUnsafeGetSignInUrlUsage( - workDir: string, -): Promise<{ file: string } | null> { +export async function findUnsafeGetSignInUrlUsage(workDir: string): Promise<{ file: string } | null> { const files = await fg('{app,src/app}/**/*.tsx', { cwd: workDir, ignore: ['**/callback/**', '**/node_modules/**'], From 55f7007d321ea79d49c98e996879ce65edf20cd0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 30 Mar 2026 14:43:57 -0500 Subject: [PATCH 5/6] refactor: simplify stripComments regex and use path.relative - Combine two-pass comment stripping into single regex - Use path.relative() instead of fragile string replacement --- tests/evals/graders/nextjs.grader.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/evals/graders/nextjs.grader.ts b/tests/evals/graders/nextjs.grader.ts index c350ea0f..6caf7374 100644 --- a/tests/evals/graders/nextjs.grader.ts +++ b/tests/evals/graders/nextjs.grader.ts @@ -1,5 +1,6 @@ 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'; @@ -24,7 +25,7 @@ const INVOCATION_PATTERN = /\bgetSignInUrl\s*\(/; * so the invocation regex doesn't match commented-out calls. */ function stripComments(content: string): string { - return content.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); + return content.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, ''); } export async function findUnsafeGetSignInUrlUsage(workDir: string): Promise<{ file: string } | null> { @@ -43,7 +44,7 @@ export async function findUnsafeGetSignInUrlUsage(workDir: string): Promise<{ fi !hasTopLevelDirective(content, 'use client') && !hasTopLevelDirective(content, 'use server') ) { - return { file: file.replace(workDir + '/', '') }; + return { file: relative(workDir, file) }; } } From 8f3b4f0abe1a183d8993d2f08696ab171f0d4216 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 31 Mar 2026 15:14:51 -0500 Subject: [PATCH 6/6] chore: bump @workos/skills to 0.2.4 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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