From 4b8c6b10395452a3fd1ff7ea4eb919289b66f33f Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 21 Aug 2026 19:01:26 +0700 Subject: [PATCH] feat(api): Task 04 implement safe atomic snapshot unlock SNAPSHOT_LOCKED -> READY --- .../TASK-2026-08-21-04-snapshot-unlock.md | 56 +++ .../TASK-2026-08-21-04-snapshot-unlock.md | 27 ++ src/app/api/giveaways/[id]/unlock/route.ts | 77 +++++ src/app/giveaways/new/page.tsx | 40 ++- src/lib/giveaway-store.ts | 4 + src/lib/repository/giveaway-repository.ts | 1 + src/lib/repository/memory-repository.ts | 22 ++ src/lib/repository/prisma-repository.ts | 41 +++ tests/snapshot-unlock.test.ts | 327 ++++++++++++++++++ tests/storage-driver.test.ts | 1 + 10 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 agents/antigravity/done/TASK-2026-08-21-04-snapshot-unlock.md create mode 100644 agents/antigravity/inbox/TASK-2026-08-21-04-snapshot-unlock.md create mode 100644 src/app/api/giveaways/[id]/unlock/route.ts create mode 100644 tests/snapshot-unlock.test.ts diff --git a/agents/antigravity/done/TASK-2026-08-21-04-snapshot-unlock.md b/agents/antigravity/done/TASK-2026-08-21-04-snapshot-unlock.md new file mode 100644 index 0000000..495edf5 --- /dev/null +++ b/agents/antigravity/done/TASK-2026-08-21-04-snapshot-unlock.md @@ -0,0 +1,56 @@ +# Task 04: Разблокировка SNAPSHOT_LOCKED → READY Report + +**Date:** 2026-08-21 +**Base Commit SHA:** `fb6ae616285aebe4ef6ac1436cd0861664f3ba0d` +**Status:** COMPLETED / PASS +**Assigned Agent:** Antigravity (Implementation Orchestrator) + +--- + +## 1. Executive Summary + +Реализован механизм безопасной и атомарной разблокировки розыгрыша (`SNAPSHOT_LOCKED → READY`), устраняющий проблему невозвратной блокировки на шаге 4 визарда. + +Ключевые свойства реализации: +1. **Атомарность и защита от Seed Grinding:** При вызове разблокировки поле `giveaway.seed` и `seedCommitment` принудительно сбрасываются в `null` в рамках единой транзакции с условным переходом статуса (`updateMany where status = 'SNAPSHOT_LOCKED'`). +2. **Новый CSPRNG seed при повторной фиксации:** При повторном вызове `/api/giveaways/[id]/snapshot` генерируется новый криптостойкий seed и новый commitment SHA-256. +3. **Обоснование стратегии версионирования снапшотов:** Предыдущие записи `ParticipantSnapshot` сохраняются в базе данных, а поле `version` инкрементируется (`version = max(version) + 1`). Это оживляет версионирование и сохраняет полную историю условий розыгрыша, в то время как `drawResult` и `auditRecord` связываются исключительно с финальным `snapshotId`. +4. **Безопасность и авторизация:** Новый эндпоинт `POST /api/giveaways/[id]/unlock` защищен CSRF-guard (`validateCsrfOrigin`), проверкой владения организатором (`requireGiveawayOwner`), лимитером частоты (`expensiveApiRateLimiter`) и поддержкой `Idempotency-Key`. +5. **UI Integration:** На шаге 4 визарда добавлена кнопка возврата к шагу 3 с вызовом `/api/giveaways/[id]/unlock` и сбросом клиентского состояния commitment. + +--- + +## 2. Modified Files + +| File | Type | Description | +|------|------|-------------| +| `src/lib/repository/giveaway-repository.ts` | Interface | Добавлен метод `unlockSnapshot(id: string): Promise`. | +| `src/lib/repository/memory-repository.ts` | Repository | Реализован `unlockSnapshot` с атомарным сбросом `seed`, `seedCommitment`, `latestSnapshot` и переходом в `READY`. | +| `src/lib/repository/prisma-repository.ts` | Repository | Реализован `unlockSnapshot` через транзакционный условный `updateMany` (`SNAPSHOT_LOCKED → READY`, `seed: null`). | +| `src/lib/giveaway-store.ts` | Store | Добавлен фасад `GiveawayStore.unlockSnapshot(id)`. | +| `src/app/api/giveaways/[id]/unlock/route.ts` | API Route (NEW) | Защищенный HTTP-эндпоинт разблокировки с CSRF, auth, rate limit и идемпотентностью. | +| `src/app/giveaways/new/page.tsx` | UI | Обработчик `handleUnlockAndReturnToStep3` и кнопка возврата с шага 4 к шагу 3. | +| `tests/snapshot-unlock.test.ts` | Tests (NEW) | 6 тестов на полный жизненный цикл, IDOR, терминальные состояния, конкурентность и идемпотентность. | +| `tests/storage-driver.test.ts` | Tests | Обновлен mock-объект `failingDbRepo` интерфейса `IGiveawayRepository`. | + +--- + +## 3. Architecture & Security Invariants + +- **Terminal State Protection:** Из статусов `DRAWN` и `PUBLISHED` разблокировка строго запрещена — возвращается `409 Conflict`. +- **Ownership (IDOR):** Запросы разблокировки чужого розыгрыша возвращают `403 Forbidden`. +- **Single Flight / Concurrency:** Конкурентные запросы разблокировки гарантируют ровно один переход `200 OK`, все остальные получают `409 Conflict`. +- **Core Randomizer Invariant:** Криптографический алгоритм `HMAC_SHA256_FY_V1` и формат proof сохранены в строгом соответствии с `AGENTS.md`. + +--- + +## 4. 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 (52 test files, 306 tests passed, 0 failed) +npm run lint -> EXIT 0 (0 errors, 6 warnings on no-img-element) +npm run build -> EXIT 0 (All 16 routes compiled and static pages generated) +npm audit --omit=dev -> EXIT 0 (0 vulnerabilities) +``` diff --git a/agents/antigravity/inbox/TASK-2026-08-21-04-snapshot-unlock.md b/agents/antigravity/inbox/TASK-2026-08-21-04-snapshot-unlock.md new file mode 100644 index 0000000..bd619f9 --- /dev/null +++ b/agents/antigravity/inbox/TASK-2026-08-21-04-snapshot-unlock.md @@ -0,0 +1,27 @@ +# Task 04: Разблокировка SNAPSHOT_LOCKED → READY + +**Assigned to:** Antigravity (Implementation Orchestrator) +**Priority:** MEDIUM (functional regression) +**Date:** 2026-08-21 +**Base SHA:** `fb6ae616285aebe4ef6ac1436cd0861664f3ba0d` + +## Scope +1. Implement `POST /api/giveaways/[id]/unlock` endpoint: + - Security: `requireGiveawayOwner`, CSRF-guard, user-scoped rate limiting (`expensiveApiRateLimiter`), `Idempotency-Key` support. + - Atomic state transition `SNAPSHOT_LOCKED` -> `READY`. + - Rejects `DRAWN` and `PUBLISHED` states with `409 Conflict`. +2. Atomic repository transition `unlockSnapshot(id: string)` in both `MemoryGiveawayRepository` and `PrismaGiveawayRepository`: + - Enforce condition `status: 'SNAPSHOT_LOCKED'`. + - Reset `seed: null` in DB in the same atomic transaction. + - Versioning strategy: keep historical snapshots with incrementing version (`version = max(version) + 1` upon next lock) or manage previous snapshot records cleanly. +3. Expose `GiveawayStore.unlockSnapshot(id)`. +4. Update UI: on Step 4 of the wizard (`src/app/giveaways/new/page.tsx`), add a button to unlock snapshot and return to Step 3 with filter adjustment. +5. Create regression and concurrency test suite `tests/snapshot-unlock.test.ts`: + - Full cycle: lock -> unlock -> change rules -> lock -> draw. + - Seed and commitment before and after unlock/re-lock are different (CSPRNG re-generated). + - Unlock from `DRAWN` -> `409 Conflict`. + - Unlock of another user's giveaway -> `403 Forbidden`. + - Concurrent unlock requests: exactly 1 succeeds, remaining return `409 Conflict`. +6. Verification Gate: + - `npm ci`, `npx prisma generate`, `npm test`, `npm run lint`, `npm run build`, `npx tsc --noEmit`. +7. Output report in `agents/antigravity/done/TASK-2026-08-21-04-snapshot-unlock.md`. diff --git a/src/app/api/giveaways/[id]/unlock/route.ts b/src/app/api/giveaways/[id]/unlock/route.ts new file mode 100644 index 0000000..dcff480 --- /dev/null +++ b/src/app/api/giveaways/[id]/unlock/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { GiveawayStore } from '@/lib/giveaway-store'; +import { handleApiError, ConflictError } from '@/core/errors/http-errors'; +import { expensiveApiRateLimiter } from '@/lib/rate-limiter'; +import { IdempotencyStore } from '@/lib/idempotency'; +import { requireGiveawayOwner } from '@/lib/auth/auth-guard'; +import { validateCsrfOrigin } from '@/lib/auth/csrf-guard'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> | { id: string } } +) { + try { + // 1. Enforce CSRF Origin validation for mutating request + validateCsrfOrigin(req); + + const { id } = await params; + + // 2. Enforce giveaway ownership authorization + const { giveaway, sessionUser } = await requireGiveawayOwner(req, id); + + // 3. User-scoped rate limiter + expensiveApiRateLimiter.assertAllowed(`snapshot-unlock:${sessionUser.id}:${id}`); + + const idempotencyKey = req.headers.get('idempotency-key'); + if (idempotencyKey) { + const cached = IdempotencyStore.get({ + key: idempotencyKey, + operation: 'snapshot-unlock', + giveawayId: id, + }); + if (cached) { + return NextResponse.json(cached.body, { status: cached.statusCode }); + } + } + + // 4. Strict Terminal State Guard: cannot unlock DRAWN or PUBLISHED giveaways + if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') { + throw new ConflictError( + `Cannot unlock snapshot for giveaway in final status "${giveaway.status}"` + ); + } + + if (giveaway.status !== 'SNAPSHOT_LOCKED') { + throw new ConflictError( + `Cannot unlock snapshot: giveaway "${id}" is in status "${giveaway.status}", but requires "SNAPSHOT_LOCKED"` + ); + } + + // 5. Atomically transition SNAPSHOT_LOCKED -> READY and reset pre-committed seed + const updated = await GiveawayStore.unlockSnapshot(id); + + const responseBody = { + success: true, + giveawayId: id, + status: updated.status, + seedCommitment: null, + message: 'Snapshot unlocked successfully and seed commitment cleared', + }; + + if (idempotencyKey) { + IdempotencyStore.set({ + key: idempotencyKey, + operation: 'snapshot-unlock', + giveawayId: id, + statusCode: 200, + body: responseBody, + }); + } + + return NextResponse.json(responseBody); + } catch (error: any) { + return handleApiError(error); + } +} diff --git a/src/app/giveaways/new/page.tsx b/src/app/giveaways/new/page.tsx index 7fd5e55..6d60d2c 100644 --- a/src/app/giveaways/new/page.tsx +++ b/src/app/giveaways/new/page.tsx @@ -67,6 +67,7 @@ export default function NewGiveawayWizardPage() { const [winnersCount, setWinnersCount] = useState(1); const [reserveWinnersCount, setReserveWinnersCount] = useState(1); const [drawing, setDrawing] = useState(false); + const [unlockingSnapshot, setUnlockingSnapshot] = useState(false); // Step 5: Results const [drawResult, setDrawResult] = useState(null); @@ -195,6 +196,31 @@ export default function NewGiveawayWizardPage() { } }; + // Step 4 handler: Unlock Snapshot & Return to Step 3 + const handleUnlockAndReturnToStep3 = async () => { + if (!createdGiveawayId) { + setStep(3); + return; + } + setUnlockingSnapshot(true); + try { + const res = await fetch(`/api/giveaways/${createdGiveawayId}/unlock`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error?.message || data.error || 'Ошибка разблокировки слепка'); + + setLockedSnapshot(null); + setSeedCommitment(null); + setStep(3); + } catch (err: any) { + alert(err.message); + } finally { + setUnlockingSnapshot(false); + } + }; + // Step 4 handler: Execute Draw const handleExecuteDraw = async () => { if (!createdGiveawayId) return; @@ -832,10 +858,18 @@ export default function NewGiveawayWizardPage() {