Skip to content

Commit f2a6ec2

Browse files
authored
fix: add NextjsGrader check for getSignInUrl in Server Components (#110)
* 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(). * 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. * 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. * chore: formatting * refactor: simplify stripComments regex and use path.relative - Combine two-pass comment stripping into single regex - Use path.relative() instead of fragile string replacement * chore: bump @workos/skills to 0.2.4
1 parent adeee8f commit f2a6ec2

File tree

4 files changed

+205
-6
lines changed

4 files changed

+205
-6
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"@clack/prompts": "1.0.1",
5252
"@napi-rs/keyring": "^1.2.0",
5353
"@workos-inc/node": "^8.7.0",
54-
"@workos/skills": "0.2.2",
54+
"@workos/skills": "0.2.4",
5555
"chalk": "^5.6.2",
5656
"diff": "^8.0.3",
5757
"fast-glob": "^3.3.3",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { findUnsafeGetSignInUrlUsage } from '../nextjs.grader.js';
6+
7+
describe('findUnsafeGetSignInUrlUsage', () => {
8+
let workDir: string;
9+
10+
beforeEach(async () => {
11+
workDir = await mkdtemp(join(tmpdir(), 'grader-test-'));
12+
await mkdir(join(workDir, 'app'), { recursive: true });
13+
});
14+
15+
afterEach(async () => {
16+
await rm(workDir, { recursive: true, force: true });
17+
});
18+
19+
it('fails when getSignInUrl() is called in app/page.tsx', async () => {
20+
await writeFile(
21+
join(workDir, 'app/page.tsx'),
22+
`
23+
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
24+
export default async function Page() {
25+
const url = await getSignInUrl();
26+
return <a href={url}>Sign in</a>;
27+
}
28+
`,
29+
);
30+
const result = await findUnsafeGetSignInUrlUsage(workDir);
31+
expect(result).not.toBeNull();
32+
expect(result!.file).toBe('app/page.tsx');
33+
});
34+
35+
it('fails when getSignInUrl() is in a shared component without directive', async () => {
36+
await mkdir(join(workDir, 'app/components'), { recursive: true });
37+
await writeFile(
38+
join(workDir, 'app/components/nav-auth.tsx'),
39+
`
40+
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
41+
export default async function NavAuth() {
42+
const url = await getSignInUrl();
43+
return <a href={url}>Sign in</a>;
44+
}
45+
`,
46+
);
47+
const result = await findUnsafeGetSignInUrlUsage(workDir);
48+
expect(result).not.toBeNull();
49+
expect(result!.file).toContain('nav-auth.tsx');
50+
});
51+
52+
it('passes when getSignInUrl() is in a use client component', async () => {
53+
await writeFile(
54+
join(workDir, 'app/page.tsx'),
55+
`
56+
'use client';
57+
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
58+
export default function Page() {
59+
const handleClick = async () => { const url = await getSignInUrl(); window.location.href = url; };
60+
return <button onClick={handleClick}>Sign in</button>;
61+
}
62+
`,
63+
);
64+
const result = await findUnsafeGetSignInUrlUsage(workDir);
65+
expect(result).toBeNull();
66+
});
67+
68+
it('passes when getSignInUrl() is in a top-level use server file', async () => {
69+
await mkdir(join(workDir, 'app/actions'), { recursive: true });
70+
await writeFile(
71+
join(workDir, 'app/actions/auth.tsx'),
72+
`
73+
'use server';
74+
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
75+
export async function getUrl() { return getSignInUrl(); }
76+
`,
77+
);
78+
const result = await findUnsafeGetSignInUrlUsage(workDir);
79+
expect(result).toBeNull();
80+
});
81+
82+
it('fails when use server is inline, not top-level', async () => {
83+
await writeFile(
84+
join(workDir, 'app/page.tsx'),
85+
`
86+
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
87+
export default async function Page() {
88+
const url = await getSignInUrl();
89+
async function logout() {
90+
'use server';
91+
// server action
92+
}
93+
return <a href={url}>Sign in</a>;
94+
}
95+
`,
96+
);
97+
const result = await findUnsafeGetSignInUrlUsage(workDir);
98+
expect(result).not.toBeNull();
99+
});
100+
101+
it('passes when no files contain getSignInUrl()', async () => {
102+
await writeFile(
103+
join(workDir, 'app/page.tsx'),
104+
`
105+
export default function Page() {
106+
return <h1>Home</h1>;
107+
}
108+
`,
109+
);
110+
const result = await findUnsafeGetSignInUrlUsage(workDir);
111+
expect(result).toBeNull();
112+
});
113+
114+
it('ignores mere mention of getSignInUrl without invocation', async () => {
115+
await writeFile(
116+
join(workDir, 'app/page.tsx'),
117+
`
118+
// Do not use getSignInUrl in server components
119+
export default function Page() { return <h1>Home</h1>; }
120+
`,
121+
);
122+
const result = await findUnsafeGetSignInUrlUsage(workDir);
123+
expect(result).toBeNull();
124+
});
125+
126+
it('ignores commented-out getSignInUrl() calls', async () => {
127+
await writeFile(
128+
join(workDir, 'app/page.tsx'),
129+
`
130+
// don't call getSignInUrl() here
131+
/* const url = await getSignInUrl(); */
132+
export default function Page() { return <h1>Home</h1>; }
133+
`,
134+
);
135+
const result = await findUnsafeGetSignInUrlUsage(workDir);
136+
expect(result).toBeNull();
137+
});
138+
});

tests/evals/graders/nextjs.grader.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,63 @@
1+
import fg from 'fast-glob';
2+
import { readFile } from 'node:fs/promises';
3+
import { relative } from 'node:path';
14
import { FileGrader } from './file-grader.js';
25
import { BuildGrader } from './build-grader.js';
36
import type { Grader, GradeResult, GradeCheck } from '../types.js';
47

8+
/**
9+
* Module prologue directive check.
10+
* Only matches 'use client' / 'use server' when they appear as the
11+
* first statement in the file (ignoring leading comments and whitespace).
12+
* Does NOT match inline 'use server' inside function bodies.
13+
*/
14+
export function hasTopLevelDirective(content: string, directive: string): boolean {
15+
// Strip leading whitespace, single-line comments, and multi-line comments
16+
const stripped = content.replace(/^\s*(\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/g, '');
17+
// Check if the file starts with the directive (single or double quotes, with semicolon optional)
18+
return stripped.startsWith(`'${directive}'`) || stripped.startsWith(`"${directive}"`);
19+
}
20+
21+
const INVOCATION_PATTERN = /\bgetSignInUrl\s*\(/;
22+
23+
/**
24+
* Strip single-line (//) and multi-line comments from source code
25+
* so the invocation regex doesn't match commented-out calls.
26+
*/
27+
function stripComments(content: string): string {
28+
return content.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, '');
29+
}
30+
31+
export async function findUnsafeGetSignInUrlUsage(workDir: string): Promise<{ file: string } | null> {
32+
const files = await fg('{app,src/app}/**/*.tsx', {
33+
cwd: workDir,
34+
ignore: ['**/callback/**', '**/node_modules/**'],
35+
absolute: true,
36+
});
37+
38+
for (const file of files) {
39+
const content = await readFile(file, 'utf-8');
40+
const code = stripComments(content);
41+
42+
if (
43+
INVOCATION_PATTERN.test(code) &&
44+
!hasTopLevelDirective(content, 'use client') &&
45+
!hasTopLevelDirective(content, 'use server')
46+
) {
47+
return { file: relative(workDir, file) };
48+
}
49+
}
50+
51+
return null;
52+
}
53+
554
export class NextjsGrader implements Grader {
655
private fileGrader: FileGrader;
756
private buildGrader: BuildGrader;
57+
private workDir: string;
858

959
constructor(workDir: string) {
60+
this.workDir = workDir;
1061
this.fileGrader = new FileGrader(workDir);
1162
this.buildGrader = new BuildGrader(workDir);
1263
}
@@ -91,6 +142,16 @@ export class NextjsGrader implements Grader {
91142
);
92143
checks.push(authKitProviderCheck);
93144

145+
// Check for getSignInUrl() in server components (no top-level directive)
146+
const unsafeUsage = await findUnsafeGetSignInUrlUsage(this.workDir);
147+
checks.push({
148+
name: 'No getSignInUrl in Server Components',
149+
passed: unsafeUsage === null,
150+
message: unsafeUsage
151+
? `${unsafeUsage.file} calls getSignInUrl() without a top-level 'use client' or 'use server' directive — will throw in Next.js 15+`
152+
: 'No unsafe getSignInUrl usage in Server Components',
153+
});
154+
94155
// Check build succeeds
95156
checks.push(await this.buildGrader.checkBuild());
96157

0 commit comments

Comments
 (0)