fix(nextjs): break server-only import leak in auth-store

auth-store.ts directly imported fetch-session, login, and register
which transitively pulled next/headers and server-only into the client
bundle, causing the Next.js 16 build to fail.

Replaced direct imports with fetch() calls to new API route handlers
at /api/auth/{login,register,session,logout}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 04:38:28 +00:00
parent 7fa19e4db7
commit 96d16564a4
5 changed files with 219 additions and 15 deletions

View File

@@ -0,0 +1,58 @@
/**
* POST /api/auth/login
*
* Authenticates a user and sets a session cookie.
*/
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { login } from '@/lib/auth/api/login'
import { db } from '@/lib/db-client'
import crypto from 'crypto'
export async function POST(request: Request): Promise<NextResponse> {
try {
const body = await request.json() as { identifier?: string; password?: string }
const { identifier, password } = body
if (identifier === undefined || password === undefined) {
return NextResponse.json(
{ success: false, user: null, error: 'Identifier and password are required' },
{ status: 400 },
)
}
const result = await login(identifier, password)
if (!result.success || result.user === null) {
return NextResponse.json(result, { status: 401 })
}
// Create a session token and persist it
const sessionToken = crypto.randomUUID()
await db.sessions.create({
id: `sess_${sessionToken}`,
userId: result.user.id ?? '',
token: sessionToken,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
})
// Set session cookie
const cookieStore = await cookies()
cookieStore.set('session_token', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60,
})
return NextResponse.json(result)
} catch (error) {
console.error('Login route error:', error)
return NextResponse.json(
{ success: false, user: null, error: 'Internal server error' },
{ status: 500 },
)
}
}

View File

@@ -0,0 +1,33 @@
/**
* POST /api/auth/logout
*
* Clears the session cookie and invalidates the session.
*/
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { db } from '@/lib/db-client'
export async function POST(): Promise<NextResponse> {
try {
const cookieStore = await cookies()
const sessionToken = cookieStore.get('session_token')?.value
// Remove session from DBAL if it exists
if (sessionToken !== undefined && sessionToken.length > 0) {
const sessions = await db.sessions.list({ filter: { token: sessionToken } })
const session = sessions.data[0] as { id: string } | undefined
if (session !== undefined) {
await db.sessions.remove(session.id)
}
}
// Clear cookie
cookieStore.delete('session_token')
return NextResponse.json({ success: true })
} catch (error) {
console.error('Logout route error:', error)
return NextResponse.json({ success: true })
}
}

View File

@@ -0,0 +1,62 @@
/**
* POST /api/auth/register
*
* Creates a new user account and sets a session cookie.
*/
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { register } from '@/lib/auth/api/register'
import { db } from '@/lib/db-client'
import crypto from 'crypto'
export async function POST(request: Request): Promise<NextResponse> {
try {
const body = await request.json() as {
username?: string
email?: string
password?: string
}
const { username, email, password } = body
if (username === undefined || email === undefined || password === undefined) {
return NextResponse.json(
{ success: false, user: null, error: 'Username, email, and password are required' },
{ status: 400 },
)
}
const result = await register(username, email, password)
if (!result.success || result.user === null) {
return NextResponse.json(result, { status: 400 })
}
// Create a session token and persist it
const sessionToken = crypto.randomUUID()
await db.sessions.create({
id: `sess_${sessionToken}`,
userId: result.user.id ?? '',
token: sessionToken,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
})
// Set session cookie
const cookieStore = await cookies()
cookieStore.set('session_token', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60,
})
return NextResponse.json(result)
} catch (error) {
console.error('Register route error:', error)
return NextResponse.json(
{ success: false, user: null, error: 'Internal server error' },
{ status: 500 },
)
}
}

View File

@@ -0,0 +1,23 @@
/**
* GET /api/auth/session
*
* Returns the current authenticated user from the session cookie.
*/
import { NextResponse } from 'next/server'
import { fetchSession } from '@/lib/auth/api/fetch-session'
export async function GET(): Promise<NextResponse> {
try {
const user = await fetchSession()
if (user === null) {
return NextResponse.json({ user: null }, { status: 401 })
}
return NextResponse.json({ user })
} catch (error) {
console.error('Session route error:', error)
return NextResponse.json({ user: null }, { status: 500 })
}
}

View File

@@ -1,15 +1,21 @@
/**
* @file auth-store.ts
* @description Authentication state management store
*
* All auth operations go through API routes to avoid importing
* server-only modules into the client bundle.
*/
import { fetchSession } from '@/lib/auth/api/fetch-session'
import { login as loginRequest } from '@/lib/auth/api/login'
import { logout as logoutRequest } from '@/lib/auth/api/logout'
import { register as registerRequest } from '@/lib/auth/api/register'
import type { User } from '@/lib/types/level-types'
import type { AuthState } from './auth-types'
import { mapUserToAuthUser } from './utils/map-user'
import { BASE_PATH } from '@/lib/app-config'
interface AuthApiResponse {
success: boolean
user: User | null
error?: string
}
export class AuthStore {
private state: AuthState = {
@@ -51,8 +57,15 @@ export class AuthStore {
})
try {
const result = await loginRequest(identifier, password)
const response = await fetch(`${BASE_PATH}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ identifier, password }),
})
const result = await response.json() as AuthApiResponse
if (!result.success || result.user === null) {
this.setState({
...this.state,
@@ -60,7 +73,7 @@ export class AuthStore {
})
throw new Error(result.error ?? 'Login failed')
}
this.setState({
user: mapUserToAuthUser(result.user),
isAuthenticated: true,
@@ -82,8 +95,15 @@ export class AuthStore {
})
try {
const result = await registerRequest(username, email, password)
const response = await fetch(`${BASE_PATH}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, email, password }),
})
const result = await response.json() as AuthApiResponse
if (!result.success || result.user === null) {
this.setState({
...this.state,
@@ -91,7 +111,7 @@ export class AuthStore {
})
throw new Error(result.error ?? 'Registration failed')
}
this.setState({
user: mapUserToAuthUser(result.user),
isAuthenticated: true,
@@ -108,7 +128,10 @@ export class AuthStore {
async logout(): Promise<void> {
try {
await logoutRequest()
await fetch(`${BASE_PATH}/api/auth/logout`, {
method: 'POST',
credentials: 'include',
})
} finally {
this.setState({
user: null,
@@ -125,10 +148,15 @@ export class AuthStore {
})
try {
const user = await fetchSession()
if (user !== null && user !== undefined) {
const response = await fetch(`${BASE_PATH}/api/auth/session`, {
credentials: 'include',
})
const result = await response.json() as { user: User | null }
if (result.user !== null && result.user !== undefined) {
this.setState({
user: mapUserToAuthUser(user),
user: mapUserToAuthUser(result.user),
isAuthenticated: true,
isLoading: false,
})