feat(infra): Task 03 upgrade to Next.js 16 and React 19 resolving high security advisories

This commit is contained in:
Ochenstarik 2026-08-21 18:42:40 +07:00
parent 6f3fd44333
commit fb6ae61628
13 changed files with 1933 additions and 1128 deletions

View file

@ -0,0 +1,104 @@
# Task 03: Next.js Major Upgrade Report (Eliminating High Advisories)
**Date:** 2026-08-21
**Base Commit SHA:** `6f3fd44333cbb200d82efa665d191f660b100144`
**Status:** COMPLETED / PASS
**Assigned Agent:** Antigravity (Implementation Orchestrator)
---
## 1. Executive Summary
Выполнен мажорный апгрейд инфраструктуры Next.js и React:
- `next`: `14.2.15``16.3.2`
- `react` & `react-dom`: `18.3.1``19.2.8`
- `@types/react` & `@types/react-dom`: `^19.2.18` / `^19.2.4`
- `eslint` & `eslint-config-next`: `^9.20.0` / `^16.3.2`
- `postcss`: `^8.5.26`
В результате `npm audit --omit=dev` возвращает **0 vulnerabilities** (устранены уязвимости `next` GHSA-955p-x3mx-jcvp и `postcss` GHSA-6g55-p6wh-862q, GHSA-fxqj-rqcc-2cmp, GHSA-r28c-9q8g-f849, GHSA-qx2v-qp2m-jg93).
Все 300 тестов проходят без изменений бизнес-логики и криптографических инвариантов.
---
## 2. Initial vs Final Audit Output
### Initial `npm audit --omit=dev`:
```text
2 high severity vulnerabilities
- next 9.3.4-canary.0 - 16.3.0-preview.10 (GHSA-955p-x3mx-jcvp)
- postcss <=8.5.22 (GHSA-6g55-p6wh-862q, GHSA-fxqj-rqcc-2cmp, GHSA-r28c-9q8g-f849, GHSA-qx2v-qp2m-jg93)
```
### Final `npm audit --omit=dev`:
```text
found 0 vulnerabilities
```
---
## 3. Breaking Changes & Migration Details
### 1. App Router Dynamic Route Parameters (`Promise<params>`)
В Next.js 15+ аргумент `params` в Route Handlers передаётся как `Promise`.
Обновлены все 5 динамических эндпоинтов:
- `src/app/api/giveaways/[id]/route.ts`
- `src/app/api/giveaways/[id]/draw/route.ts`
- `src/app/api/giveaways/[id]/participants/route.ts`
- `src/app/api/giveaways/[id]/snapshot/route.ts`
- `src/app/api/giveaways/[id]/verify/route.ts`
Сигнатура параметров типизирована как `{ params: Promise<{ id: string }> | { id: string } }` и распаковывается через `const { id } = await params;`, что обеспечивает 100% совместимость.
### 2. `NextRequest.ip` Type Definition
В Next.js 15+ поле `ip` удалено из интерфейса `NextRequest`.
В `src/lib/client-ip.ts` реализован безопасный доступ к сокетному IP: `(req as unknown as { ip?: string }).ip`.
### 3. Flat Config для ESLint 9 (`eslint.config.mjs`)
Next.js 16 и ESLint 9 перешли на плоскую конфигурацию (flat config).
Создан файл `eslint.config.mjs`, экспортирующий массив `nextConfig` из `eslint-config-next`. Скрипт `lint` в `package.json` переведён на `eslint .`.
### 4. React 19 Strict Hook Rules (`react-hooks/set-state-in-effect`)
В `src/app/page.tsx` устранён синхронный вызов `setLoading(true)` при инициализации хука `useEffect`.
---
## 4. Modified Files
| File | Type | Description |
|------|------|-------------|
| `package.json` | Dependencies | Обновлены версии `next`, `react`, `react-dom`, `eslint`, `eslint-config-next`, `postcss`. Скрипт `"lint": "eslint ."`. |
| `package-lock.json` | Lockfile | Обновлены зависимости и транзитивные деревья. |
| `eslint.config.mjs` | Config (NEW) | Конфигурация ESLint 9 Flat Config для Next.js 16. |
| `tsconfig.json` | Config | Обновлён Next.js (`jsx: react-jsx`, `.next/dev/types/**/*.ts`). |
| `src/lib/client-ip.ts` | Infrastructure | Безопасный доступ к `directIp` для Next.js 16. |
| `src/app/api/giveaways/[id]/route.ts` | API Route | Поддержка `Promise<params>`. |
| `src/app/api/giveaways/[id]/draw/route.ts` | API Route | Поддержка `Promise<params>`. |
| `src/app/api/giveaways/[id]/participants/route.ts` | API Route | Поддержка `Promise<params>`. |
| `src/app/api/giveaways/[id]/snapshot/route.ts` | API Route | Поддержка `Promise<params>`. |
| `src/app/api/giveaways/[id]/verify/route.ts` | API Route | Поддержка `Promise<params>`. |
| `src/app/page.tsx` | UI | Соблюдение правил React 19 `set-state-in-effect`. |
---
## 5. Verification Evidence & Test Gate
Фактически выполненные команды:
```text
npx prisma generate -> EXIT 0 (Prisma Client v5.22.0)
npx tsc --noEmit -> EXIT 0 (Clean TypeScript check, 0 errors)
npm test -> EXIT 0 (51 test files, 300 tests passed, 0 failed)
npm run lint -> EXIT 0 (0 errors, 6 warnings on no-img-element)
npm run build -> EXIT 0 (Compiled with Turbopack, all 15 routes generated)
npm audit --omit=dev -> EXIT 0 (found 0 vulnerabilities)
```
---
## 6. Core Invariants & Security
- **Randomizer / Audit Proof Invariants:** Алгоритмы `HMAC_SHA256_FY_V1`, `executeDeterministicDrawV1`, `verifyDrawResult` сохранены без изменений.
- **Fail-Closed Authorization:** Сохранены все auth-guards, ownership checks, PKCE S256 и CSRF валидация.
- **Dependencies:** `prisma` / `@prisma/client` намеренно не обновлялись в этой задаче в соответствии со Scope (выделено в отдельное запланированное обновление).

View file

@ -0,0 +1,26 @@
# Task 03: Next.js Major Upgrade (устранение 2 high advisories)
**Assigned to:** Antigravity (Implementation Orchestrator)
**Priority:** HIGH (security)
**Date:** 2026-08-21
**Base SHA:** `6f3fd44333cbb200d82efa665d191f660b100144`
## Scope
1. Check `npm audit --omit=dev` and record the exact vulnerability output.
2. Upgrade `next`, `eslint-config-next`, `@types/react`, `@types/react-dom`, React/React-DOM as needed.
3. Review and adapt Next.js App Router breaking changes:
- Dynamic route handlers: `params` as Promise in Next.js 15+ (`{ params }: { params: Promise<{ id: string }> }` or `Promise.resolve(params)`).
- `NextRequest.ip` handling in `src/lib/client-ip.ts`.
- `next.config.mjs` compatibility.
- Client components: `useParams()` in `src/app/giveaways/[id]/page.tsx`.
- `export const dynamic = 'force-dynamic'` across all route handlers.
4. Update `.github/workflows/ci.yml` if Node version requirements change.
5. Verification Gate:
- `npm ci` / `npm install`
- `npx prisma generate`
- `npm test`
- `npm run lint`
- `npm run build`
- `npx tsc --noEmit`
- `npm audit --omit=dev`
6. Output report in `agents/antigravity/done/TASK-2026-08-21-03-next-major-upgrade.md`.

7
eslint.config.mjs Normal file
View file

@ -0,0 +1,7 @@
import nextConfig from 'eslint-config-next';
const eslintConfig = [
...nextConfig,
];
export default eslintConfig;

2820
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
@ -16,20 +16,20 @@
"@prisma/client": "^5.20.0", "@prisma/client": "^5.20.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"next": "^14.2.15", "next": "^16.3.2",
"react": "^18.3.1", "react": "^19.2.8",
"react-dom": "^18.3.1", "react-dom": "^19.2.8",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.16.11", "@types/node": "^20.16.11",
"@types/react": "^18.3.11", "@types/react": "^19.2.18",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^19.2.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.1", "eslint": "^9.20.0",
"eslint-config-next": "^14.2.15", "eslint-config-next": "^16.3.2",
"postcss": "^8.4.47", "postcss": "^8.5.26",
"prisma": "^5.20.0", "prisma": "^5.20.0",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"typescript": "^5.6.3", "typescript": "^5.6.3",

View file

@ -17,10 +17,10 @@ export const dynamic = 'force-dynamic';
export async function POST( export async function POST(
req: NextRequest, req: NextRequest,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> | { id: string } }
) { ) {
try { try {
const { id } = params; const { id } = await params;
// 1. Enforce giveaway ownership authorization (extracts trusted sessionUser) // 1. Enforce giveaway ownership authorization (extracts trusted sessionUser)
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id); const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);

View file

@ -13,10 +13,10 @@ export const dynamic = 'force-dynamic';
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> | { id: string } }
) { ) {
try { try {
const { id } = params; const { id } = await params;
// 1. Enforce giveaway ownership authorization (private participant PII data) // 1. Enforce giveaway ownership authorization (private participant PII data)
const { sessionUser } = await requireGiveawayOwner(req, id); const { sessionUser } = await requireGiveawayOwner(req, id);
@ -43,10 +43,10 @@ export async function GET(
export async function POST( export async function POST(
req: NextRequest, req: NextRequest,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> | { id: string } }
) { ) {
try { try {
const { id } = params; const { id } = await params;
// 1. Enforce giveaway ownership authorization for importing participants // 1. Enforce giveaway ownership authorization for importing participants
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id); const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);

View file

@ -11,10 +11,10 @@ export const dynamic = 'force-dynamic';
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> | { id: string } }
) { ) {
try { try {
const { id } = params; const { id } = await params;
// 1. Enforce giveaway ownership authorization // 1. Enforce giveaway ownership authorization
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id); const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);

View file

@ -13,10 +13,10 @@ export const dynamic = 'force-dynamic';
export async function POST( export async function POST(
req: NextRequest, req: NextRequest,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> | { id: string } }
) { ) {
try { try {
const { id } = params; const { id } = await params;
// 1. Enforce giveaway ownership authorization // 1. Enforce giveaway ownership authorization
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id); const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);

View file

@ -9,10 +9,10 @@ export const dynamic = 'force-dynamic';
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> | { id: string } }
) { ) {
try { try {
const { id } = params; const { id } = await params;
const clientIp = resolveClientIp(req); const clientIp = resolveClientIp(req);
expensiveApiRateLimiter.assertAllowed(`verify-get:${clientIp}:${id}`); expensiveApiRateLimiter.assertAllowed(`verify-get:${clientIp}:${id}`);

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { import {
Gift, Gift,
@ -19,9 +19,9 @@ export default function DashboardPage() {
const [giveaways, setGiveaways] = useState<GiveawaySummary[]>([]); const [giveaways, setGiveaways] = useState<GiveawaySummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchGiveaways = async () => { const fetchGiveaways = useCallback(async () => {
try {
setLoading(true); setLoading(true);
try {
const res = await fetch('/api/giveaways'); const res = await fetch('/api/giveaways');
const data = await res.json(); const data = await res.json();
if (data.giveaways) { if (data.giveaways) {
@ -32,10 +32,29 @@ export default function DashboardPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
useEffect(() => { useEffect(() => {
fetchGiveaways(); let ignore = false;
fetch('/api/giveaways')
.then(res => res.json())
.then(data => {
if (!ignore && data.giveaways) {
setGiveaways(data.giveaways);
}
})
.catch(err => {
console.error(err);
})
.finally(() => {
if (!ignore) {
setLoading(false);
}
});
return () => {
ignore = true;
};
}, []); }, []);
const completedCount = giveaways.filter(g => g.status === 'DRAWN' || g.status === 'PUBLISHED').length; const completedCount = giveaways.filter(g => g.status === 'DRAWN' || g.status === 'PUBLISHED').length;

View file

@ -45,10 +45,11 @@ let hasWarnedMissingProxy = false;
*/ */
export function resolveClientIp(req: NextRequest): string { export function resolveClientIp(req: NextRequest): string {
const isTrustProxy = process.env.TRUST_PROXY === 'true'; const isTrustProxy = process.env.TRUST_PROXY === 'true';
const directIp = (req as unknown as { ip?: string }).ip;
if (!isTrustProxy) { if (!isTrustProxy) {
if (req.ip) { if (directIp) {
return normalizeIp(req.ip); return normalizeIp(directIp);
} }
if (process.env.NODE_ENV === 'production' && !hasWarnedMissingProxy) { if (process.env.NODE_ENV === 'production' && !hasWarnedMissingProxy) {
@ -68,7 +69,7 @@ export function resolveClientIp(req: NextRequest): string {
const xRealIp = req.headers.get('x-real-ip'); const xRealIp = req.headers.get('x-real-ip');
const cfConnectingIp = req.headers.get('cf-connecting-ip'); const cfConnectingIp = req.headers.get('cf-connecting-ip');
const rawHeader = xForwardedFor || xRealIp || cfConnectingIp || req.ip; const rawHeader = xForwardedFor || xRealIp || cfConnectingIp || directIp;
if (!rawHeader) { if (!rawHeader) {
return 'unknown-client'; return 'unknown-client';
@ -81,7 +82,7 @@ export function resolveClientIp(req: NextRequest): string {
// Handle multi-value proxy chains: "client, proxy1, proxy2" // Handle multi-value proxy chains: "client, proxy1, proxy2"
// The first (leftmost) entry is the client-reported IP // The first (leftmost) entry is the client-reported IP
const parts = rawHeader.split(',').map(s => s.trim()).filter(Boolean); const parts = rawHeader.split(',').map((s: string) => s.trim()).filter(Boolean);
if (parts.length === 0) { if (parts.length === 0) {
return 'unknown-client'; return 'unknown-client';
} }

View file

@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2022", "target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -11,7 +15,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@ -19,9 +23,19 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }