For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a real stylist email/password login flow that issues a JWT, protects the first backend business route, and preserves the existing frontend session + redirect behavior.
Architecture: Keep the current frontend contract unchanged and implement a single access-token flow in the NestJS backend with @nestjs/jwt and bcryptjs. Preserve the shared { ok, data, error } envelope, protect customers as the first authenticated backend route, and add focused backend/frontend regression tests around login, 401, and redirect behavior.
Tech Stack: NestJS 11, Prisma, Zod config, Jest, Supertest, React 19, React Router 7, Vitest, Testing Library
auth 模块和通用鉴权层,不把 token 解析散落到各业务模块。/Users/yuchen/Documents/hairstylist 不是 Git 仓库;执行时不要编造 commit 结果。backend/package.json: add JWT and password-hash dependencies used by the auth module.backend/src/common/filters/http-exception.filter.ts: preserve business code values when an exception response object provides one.backend/src/modules/auth/auth.module.ts: wire JwtModule and auth providers.backend/src/modules/auth/auth.service.ts: query HairstylistUser, compare password, sign JWT, and return the existing LoginResultDto.backend/src/modules/auth/auth.service.spec.ts: focused unit tests for login success and invalid credentials.backend/src/modules/auth/guards/jwt-auth.guard.ts: verify the JWT from either raw Authorization: <token> or Authorization: Bearer <token>, then attach a typed user object to the request.backend/src/modules/customers/customers.controller.ts: make the first business route authenticated.backend/test/auth.e2e-spec.ts: integration tests for protected customers access.frontend/src/pages/public/__tests__/login-page.test.tsx: keep the existing error-region regression and add a success-path redirect assertion.frontend/src/services/http/__tests__/client.test.ts: verify the client adds the stored auth header and clears session on 401.frontend/src/shared/__tests__/RequireAuth.test.tsx: verify redirect to the locale login page when no token exists.Files:
backend/package.jsonbackend/src/common/filters/http-exception.filter.tsbackend/src/modules/auth/auth.module.tsbackend/src/modules/auth/auth.service.tsbackend/src/modules/auth/auth.service.spec.tsInterfaces:
PrismaService.hairstylistUser.findFirst(args): Promise<{ id: bigint; username: string; email: string; password: string; isDeleted: number } | null>JwtService.signAsync(payload: { sub: number; email: string }): Promise<string>AuthService.login(input: StylistLoginDto): Promise<LoginResultDto>Produces: exception payloads with { code: string; message: string } that the global filter returns unchanged
[ ] Step 1: Add auth dependencies and write the failing backend login unit test
// backend/package.json
{
"dependencies": {
"@nestjs/jwt": "^11.0.0",
"bcryptjs": "^2.4.3"
}
}
// backend/src/modules/auth/auth.service.spec.ts
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import { hash } from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
let prisma: {
hairstylistUser: {
findFirst: jest.Mock;
};
};
let jwtService: {
signAsync: jest.Mock;
};
beforeEach(async () => {
prisma = {
hairstylistUser: {
findFirst: jest.fn(),
},
};
jwtService = {
signAsync: jest.fn(),
};
const moduleRef = await Test.createTestingModule({
providers: [
AuthService,
{
provide: PrismaService,
useValue: prisma,
},
{
provide: JwtService,
useValue: jwtService,
},
],
}).compile();
service = moduleRef.get(AuthService);
});
it('returns the login payload when credentials are valid', async () => {
prisma.hairstylistUser.findFirst.mockResolvedValue({
id: BigInt(1),
username: 'Ogawa',
email: 'stylist@example.com',
password: await hash('Passw0rd!', 10),
isDeleted: 0,
});
jwtService.signAsync.mockResolvedValue('signed.jwt.token');
await expect(
service.login({
email: 'stylist@example.com',
password: 'Passw0rd!',
remember: false,
}),
).resolves.toEqual({
token: 'signed.jwt.token',
tokenName: 'Authorization',
stylistId: 1,
name: 'Ogawa',
email: 'stylist@example.com',
});
});
it('throws AUTH_INVALID_CREDENTIALS when the password does not match', async () => {
prisma.hairstylistUser.findFirst.mockResolvedValue({
id: BigInt(1),
username: 'Ogawa',
email: 'stylist@example.com',
password: await hash('Passw0rd!', 10),
isDeleted: 0,
});
await expect(
service.login({
email: 'stylist@example.com',
password: 'wrong-password',
remember: false,
}),
).rejects.toMatchObject<UnauthorizedException>({
response: {
code: 'AUTH_INVALID_CREDENTIALS',
message: '邮箱或密码错误',
},
});
});
});
[ ] Step 2: Run the auth service test to confirm it fails before implementation
Run:
npm test -- src/modules/auth/auth.service.spec.ts --runInBand
Expected:
FAIL src/modules/auth/auth.service.spec.ts
● AuthService › returns the login payload when credentials are valid
Stylist login is not implemented yet
[ ] Step 3: Implement dependency wiring, business-code pass-through, and real login logic
// backend/src/common/filters/http-exception.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<Response>();
if (exception instanceof HttpException) {
const status = exception.getStatus();
const payload = exception.getResponse();
const message =
typeof payload === 'object' &&
payload !== null &&
'message' in payload
? Array.isArray(payload.message)
? payload.message.join(', ')
: String(payload.message)
: exception.message;
const code =
typeof payload === 'object' &&
payload !== null &&
'code' in payload &&
typeof payload.code === 'string'
? payload.code
: `HTTP_${status}`;
response.status(status).json({
ok: false,
data: null,
error: {
code,
message,
},
});
return;
}
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
ok: false,
data: null,
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'Internal server error',
},
});
}
}
// backend/src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@Module({
imports: [
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN') ?? '7d',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard, JwtModule],
})
export class AuthModule {}
// backend/src/modules/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { compare } from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { LoginResultDto } from './dto/login-result.dto';
import { StylistLoginDto } from './dto/stylist-login.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
) {}
async login(input: StylistLoginDto): Promise<LoginResultDto> {
const stylist = await this.prisma.hairstylistUser.findFirst({
where: {
email: input.email,
isDeleted: 0,
},
});
if (!stylist) {
throw new UnauthorizedException({
code: 'AUTH_INVALID_CREDENTIALS',
message: '邮箱或密码错误',
});
}
const passwordMatched = await compare(input.password, stylist.password);
if (!passwordMatched) {
throw new UnauthorizedException({
code: 'AUTH_INVALID_CREDENTIALS',
message: '邮箱或密码错误',
});
}
const token = await this.jwtService.signAsync({
sub: Number(stylist.id),
email: stylist.email,
});
return {
token,
tokenName: 'Authorization',
stylistId: Number(stylist.id),
name: stylist.username,
email: stylist.email,
};
}
async logout(): Promise<{ success: true }> {
return { success: true };
}
}
[ ] Step 4: Re-run the auth service test and the backend linter
Run:
npm test -- src/modules/auth/auth.service.spec.ts --runInBand
npm run lint
Expected:
PASS src/modules/auth/auth.service.spec.ts
✔ No ESLint warnings or errors
Run:
git rev-parse --is-inside-work-tree
Expected:
fatal: not a git repository (or any of the parent directories): .git
Do not create a fake commit message; just continue to the next task.
Files:
backend/src/modules/auth/guards/jwt-auth.guard.tsbackend/src/modules/customers/customers.controller.tsbackend/test/auth.e2e-spec.tsInterfaces:
JwtService.verifyAsync<{ sub: number | string; email: string }>(token): Promise<{ sub: number | string; email: string }>{ stylistId: number; email: string }Produces: protected GET /api/v1/customers behavior returning 401 without a token and 200 with a valid token
[ ] Step 1: Write failing integration tests for protected customers access
// backend/test/auth.e2e-spec.ts
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/app.setup';
describe('Auth (e2e)', () => {
let app: INestApplication;
let jwtService: JwtService;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
configureApp(app);
await app.init();
jwtService = app.get(JwtService);
});
afterAll(async () => {
await app.close();
});
it('/api/v1/customers (GET) rejects unauthenticated requests', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/customers')
.expect(401);
expect(response.body).toEqual({
ok: false,
data: null,
error: {
code: 'AUTH_UNAUTHORIZED',
message: '未登录或登录已失效',
},
});
});
it('/api/v1/customers (GET) accepts a valid signed token', async () => {
const token = await jwtService.signAsync({
sub: 1,
email: 'stylist@example.com',
});
const response = await request(app.getHttpServer())
.get('/api/v1/customers')
.set('Authorization', token)
.expect(200);
expect(response.body).toEqual({
ok: true,
data: {
items: [],
total: 0,
},
error: null,
});
});
});
[ ] Step 2: Run the new backend integration test and confirm it fails
Run:
npm run test:e2e -- auth.e2e-spec.ts
Expected:
FAIL test/auth.e2e-spec.ts
● Auth (e2e) › /api/v1/customers (GET) rejects unauthenticated requests
expected 401 "Unauthorized", got 200 "OK"
[ ] Step 3: Implement JWT verification in the guard and apply it to customers
// backend/src/modules/auth/guards/jwt-auth.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
type AuthenticatedRequest = {
headers?: { authorization?: string };
user?: { stylistId: number; email: string };
};
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.extractToken(request.headers?.authorization);
if (!token) {
throw new UnauthorizedException({
code: 'AUTH_UNAUTHORIZED',
message: '未登录或登录已失效',
});
}
try {
const payload = await this.jwtService.verifyAsync<{
sub: number | string;
email: string;
}>(token);
request.user = {
stylistId: Number(payload.sub),
email: payload.email,
};
return true;
} catch {
throw new UnauthorizedException({
code: 'AUTH_UNAUTHORIZED',
message: '未登录或登录已失效',
});
}
}
private extractToken(header?: string): string | null {
if (!header) return null;
return header.startsWith('Bearer ') ? header.slice(7) : header;
}
}
// backend/src/modules/customers/customers.controller.ts
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CustomerUpsertDto } from './dto/customer-upsert.dto';
import { ListCustomersQueryDto } from './dto/list-customers-query.dto';
import { CustomersService } from './customers.service';
@UseGuards(JwtAuthGuard)
@Controller('customers')
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Get()
list(@Query() query: ListCustomersQueryDto) {
return this.customersService.list(query);
}
@Get(':id')
getById(@Param('id') id: string) {
return this.customersService.getById(id);
}
@Post()
create(@Body() body: CustomerUpsertDto) {
return this.customersService.create(body);
}
@Patch(':id')
update(@Param('id') id: string, @Body() body: CustomerUpsertDto) {
return this.customersService.update(id, body);
}
}
[ ] Step 4: Run the targeted backend integration test and the full backend test suite
Run:
npm run test:e2e -- auth.e2e-spec.ts
npm test -- --runInBand
Expected:
PASS test/auth.e2e-spec.ts
PASS src/modules/auth/auth.service.spec.ts
Run:
git rev-parse --is-inside-work-tree
Expected:
fatal: not a git repository (or any of the parent directories): .git
Do not invent a commit; continue to the frontend task.
Files:
frontend/src/pages/public/__tests__/login-page.test.tsxfrontend/src/services/http/__tests__/client.test.tsfrontend/src/shared/__tests__/RequireAuth.test.tsxInterfaces:
stylistLogin(input: { email: string; password: string; remember?: boolean }): Promise<LoginResult>setSession(token: string, tokenName?: string): voidclearSession(): void/:locale/dashboardrequest() removes the session on 401Produces: proof that RequireAuth redirects to /:locale/login when no token exists
[ ] Step 1: Add the failing frontend regression tests
// frontend/src/pages/public/__tests__/login-page.test.tsx
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import '../../../i18n'
import { LoginPage } from '../LoginPage'
const stylistLoginMock = vi.fn()
vi.mock('../../../services/api/auth', () => ({
stylistLogin: (...args: unknown[]) => stylistLoginMock(...args),
}))
describe('LoginPage', () => {
it('keeps a persistent feedback region and shows the login error inside it', async () => {
stylistLoginMock.mockRejectedValueOnce(new Error('邮箱或密码不正确'))
render(
<HelmetProvider>
<MemoryRouter initialEntries={['/zh/login']}>
<Routes>
<Route path="/:locale/login" element={<LoginPage />} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText(/邮箱|email/i), {
target: { value: 'wrong@example.com' },
})
fireEvent.change(screen.getByLabelText(/密码|password/i), {
target: { value: 'wrong-password' },
})
fireEvent.submit(screen.getByRole('button', { name: /登录|log in/i }).closest('form')!)
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('邮箱或密码不正确')
})
})
it('navigates to the dashboard after a successful login', async () => {
stylistLoginMock.mockResolvedValueOnce({
token: 'signed.jwt.token',
tokenName: 'Authorization',
stylistId: 1,
name: 'Ogawa',
email: 'stylist@example.com',
})
render(
<HelmetProvider>
<MemoryRouter initialEntries={['/zh/login']}>
<Routes>
<Route path="/:locale/login" element={<LoginPage />} />
<Route path="/:locale/dashboard" element={<div>dashboard screen</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
fireEvent.change(screen.getByLabelText(/邮箱|email/i), {
target: { value: 'stylist@example.com' },
})
fireEvent.change(screen.getByLabelText(/密码|password/i), {
target: { value: 'Passw0rd!' },
})
fireEvent.submit(screen.getByRole('button', { name: /登录|log in/i }).closest('form')!)
await waitFor(() => {
expect(screen.getByText('dashboard screen')).toBeInTheDocument()
})
})
})
// frontend/src/services/http/__tests__/client.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API_BASE, http } from '../client'
import { clearSession, getToken, getTokenName, setSession } from '../session'
describe('http client', () => {
beforeEach(() => {
vi.mocked(globalThis.fetch).mockReset()
clearSession()
})
it('uses the Vite proxy path as the default API base', () => {
expect(API_BASE).toBe('/api/v1')
})
it('sends the stored token with the stored header name', async () => {
setSession('signed.jwt.token', 'Authorization')
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, data: { ok: true }, error: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
await http.get('/customers')
expect(globalThis.fetch).toHaveBeenCalledWith('/api/v1/customers', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'signed.jwt.token',
},
})
})
it('clears the session after a 401 response', async () => {
setSession('expired.jwt.token', 'Authorization')
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: false,
data: null,
error: { code: 'AUTH_UNAUTHORIZED', message: '未登录或登录已失效' },
}),
{
status: 401,
headers: { 'content-type': 'application/json' },
},
),
)
await expect(http.get('/customers')).rejects.toMatchObject({
code: 'AUTH_UNAUTHORIZED',
message: '未登录或登录已失效',
status: 401,
})
expect(getToken()).toBeNull()
expect(getTokenName()).toBe('Authorization')
})
})
// frontend/src/shared/__tests__/RequireAuth.test.tsx
import React from 'react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { setSession } from '../../services/http/session'
import { RequireAuth } from '../RequireAuth'
describe('RequireAuth', () => {
it('redirects to the locale login page when there is no token', () => {
render(
<MemoryRouter initialEntries={['/zh/dashboard']}>
<Routes>
<Route
path="/:locale/dashboard"
element={
<RequireAuth>
<div>dashboard screen</div>
</RequireAuth>
}
/>
<Route path="/:locale/login" element={<div>login screen</div>} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('login screen')).toBeInTheDocument()
})
it('renders children when a token exists', () => {
setSession('signed.jwt.token', 'Authorization')
render(
<MemoryRouter initialEntries={['/zh/dashboard']}>
<Routes>
<Route
path="/:locale/dashboard"
element={
<RequireAuth>
<div>dashboard screen</div>
</RequireAuth>
}
/>
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('dashboard screen')).toBeInTheDocument()
})
})
[ ] Step 2: Run the frontend tests to confirm they fail before any production-code changes
Run:
npm test -- src/pages/public/__tests__/login-page.test.tsx src/services/http/__tests__/client.test.ts src/shared/__tests__/RequireAuth.test.tsx
Expected:
FAIL src/services/http/__tests__/client.test.ts
× sends the stored token with the stored header name
Expected fetch to have been called with the Authorization header
[ ] Step 3: Make the minimal frontend code adjustments only if the new tests expose a real bug
// frontend/src/services/http/client.ts
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...((options.headers as Record<string, string>) || {}),
}
const token = getToken()
if (token) {
headers[getTokenName()] = token
}
const response = await fetch(buildUrl(path), { ...options, headers })
if (response.status === 401) {
clearSession()
}
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
if (!response.ok) {
throw new ApiError('HTTP_ERROR', `请求失败: ${response.status}`, response.status)
}
return (await response.text()) as unknown as T
}
const body = (await response.json()) as ApiEnvelope<T>
if (!response.ok || !body.ok) {
const err = body.error
throw new ApiError(err?.code || 'HTTP_ERROR', err?.message || `请求失败: ${response.status}`, response.status)
}
return body.data
}
// frontend/src/pages/public/LoginPage.tsx
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!email || !password || submitting) return
setError('')
setSubmitting(true)
try {
await stylistLogin({ email, password, remember })
navigate(path('/dashboard'))
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败')
} finally {
setSubmitting(false)
}
}
If the tests already pass without touching production code, do not edit the implementation just to create churn.
Run:
npm test -- src/pages/public/__tests__/login-page.test.tsx src/services/http/__tests__/client.test.ts src/shared/__tests__/RequireAuth.test.tsx
npm run typecheck
npm run lint
Expected:
PASS src/pages/public/__tests__/login-page.test.tsx
PASS src/services/http/__tests__/client.test.ts
PASS src/shared/__tests__/RequireAuth.test.tsx
Run:
git rev-parse --is-inside-work-tree
Expected:
fatal: not a git repository (or any of the parent directories): .git
Do not fabricate a commit; move to final verification.
Files:
backend/.env only if JWT_SECRET or JWT_EXPIRES_IN are missingbackend/README.md only if a short “how to create a bcrypt login password” note is needed for local verificationInterfaces:
DATABASE_URL, JWT_SECRET, and a hairstylist_user row with a bcrypt-hashed passwordProduces: evidence that the backend can log in a real stylist and that the frontend can enter /zh/dashboard
[ ] Step 1: Verify the local auth prerequisites
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm run prisma:validate
Expected:
The schema at prisma/schema.prisma is valid
Then check that .env contains a non-placeholder JWT_SECRET. If it still equals replace-me, set a real local secret before starting the server.
Run:
cd /Users/yuchen/Documents/hairstylist/backend
npm run start:dev
Expected:
Nest application successfully started
Run:
curl -i http://127.0.0.1:3000/api/v1/auth/stylist/login \
-H 'Content-Type: application/json' \
-d '{"email":"stylist@example.com","password":"Passw0rd!","remember":false}'
Expected:
HTTP/1.1 200 OK
{"ok":true,"data":{"token":"...","tokenName":"Authorization","stylistId":1,"name":"Ogawa","email":"stylist@example.com"},"error":null}
If the database account is not yet bcrypt-backed, generate the hash with:
node -e "const { hashSync } = require('bcryptjs'); console.log(hashSync('Passw0rd!', 10))"
and update the test row manually before retrying the login request.
Run:
curl -i http://127.0.0.1:3000/api/v1/customers \
-H "Authorization: <paste-login-token-here>"
Expected:
HTTP/1.1 200 OK
{"ok":true,"data":{"items":[],"total":0},"error":null}
Also verify the unauthenticated case:
curl -i http://127.0.0.1:3000/api/v1/customers
Expected:
HTTP/1.1 401 Unauthorized
{"ok":false,"data":null,"error":{"code":"AUTH_UNAUTHORIZED","message":"未登录或登录已失效"}}
Run:
cd /Users/yuchen/Documents/hairstylist/frontend
npm run dev
Manual check:
/zh/login/zh/dashboard/zh/dashboard and confirm the page stays authenticated/zh/login401 semantics. Task 3 covers frontend submit, redirect, session storage, and 401 cleanup regressions. Task 4 covers real local verification with a bcrypt-backed account.TODO, TBD, “implement later”, or “add validation” placeholders; every task has concrete files, code snippets, and commands.AuthService.login() returns LoginResultDto; JWT payload is consistently { sub, email }; guard-attached request user is consistently { stylistId, email }; frontend tests consistently expect token, tokenName, stylistId, name, and email.