feat(public): Task 06 implement public giveaway verification endpoint and page
This commit is contained in:
parent
1a27a10847
commit
8741569817
8 changed files with 457 additions and 18 deletions
|
|
@ -16,14 +16,16 @@
|
||||||
- Черный список ID и логинов.
|
- Черный список ID и логинов.
|
||||||
- **Детерминированный Randomizer (Provably Fair)**:
|
- **Детерминированный Randomizer (Provably Fair)**:
|
||||||
- Исключен непрозрачный `Math.random()`.
|
- Исключен непрозрачный `Math.random()`.
|
||||||
- Выборка на основе HMAC-SHA256 и перетасовки Фишера-Йетса.
|
- Выборка на основе HMAC-SHA256 (`HMAC_SHA256_FY_V1`) и несмещенной перетасовки Фишера-Йетса.
|
||||||
- Snapshot Hash (SHA-256) канонического списка участников + Seed = 100% повторяемость и верифицируемость.
|
- **Seed Pre-Commitment**: SHA-256 хеш сида фиксируется и публикуется на этапе фиксации слепка до жеребьевки, исключая seed grinding.
|
||||||
|
- Snapshot Hash (SHA-256) канонического списка участников + раскрытый Seed = 100% математическая воспроизводимость.
|
||||||
|
- Публичный результат (`GET /api/giveaways/[id]/public` и страница `/giveaways/[id]`) доступен любому участнику без входа в систему.
|
||||||
- Поддержка основных и резервных призовых мест.
|
- Поддержка основных и резервных призовых мест.
|
||||||
- **Интерактивный UI**:
|
- **Интерактивный UI**:
|
||||||
- Dashboard со статистикой и списком кампаний.
|
- Dashboard со статистикой и списком кампаний.
|
||||||
- 5-шаговый визард создания розыгрыша.
|
- 5-шаговый визард создания розыгрыша.
|
||||||
- Живое превью условий и статуса допуска каждого участника с указанием причин отклонения.
|
- Живое превью условий и статуса допуска каждого участника с указанием причин отклонения.
|
||||||
- Презентация победителей и сертификат криптографического аудита.
|
- Публичная страница розыгрыша с проверкой победителей и сертификатом криптографического аудита.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Task 06: Публичная проверяемость розыгрыша Report
|
||||||
|
|
||||||
|
**Date:** 2026-08-21
|
||||||
|
**Base Commit SHA:** `1a27a10847fe510f0ed0f128087271f78a489c7b`
|
||||||
|
**Status:** COMPLETED / PASS
|
||||||
|
**Assigned Agent:** Antigravity (Implementation Orchestrator)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
Реализована архитектура публичной проверяемости результатов розыгрыша для участников и внешних наблюдателей без раскрытия персональных данных третьих лиц:
|
||||||
|
|
||||||
|
1. **Публичный API-эндпоинт (`GET /api/giveaways/[id]/public`):**
|
||||||
|
- Доступен без аутентификации, защищен rate limiter'ом `expensiveApiRateLimiter` (`giveaway-public-get:${clientIp}:${id}`).
|
||||||
|
- Возвращает метаданные публикации, условия отбора, хеш слепка `participantsSnapshotHash`, хеш условий `conditionsHash`, `algorithmVersion`.
|
||||||
|
- **`seedCommitment`:** публично доступен **и до, и после** розыгрыша.
|
||||||
|
- **`seed`:** строго скрыт (`null`) до завершения жеребьевки (`SNAPSHOT_LOCKED`), раскрывается только в статусе `DRAWN`/`PUBLISHED`.
|
||||||
|
- **Защита PII:** полные списки участников (`participants`, `eligibleParticipants`, `excludedParticipants`) исключены из публичного ответа. Публикуются только победители (имя, аватар, ID).
|
||||||
|
- Токены, учетные данные и `organizerId` исключены из ответа.
|
||||||
|
|
||||||
|
2. **Публичная страница розыгрыша (`src/app/giveaways/[id]/page.tsx`):**
|
||||||
|
- Переведена на получение данных через `/api/giveaways/[id]/public`.
|
||||||
|
- Открывается анонимным пользователям без необходимости авторизации через VK ID.
|
||||||
|
- Отображает карточки победителей, хеш сида `Seed Commitment (SHA-256)`, `deterministicProofHash`, `auditEventHash` и кнопку онлайн-верификации (`/api/giveaways/[id]/verify`).
|
||||||
|
|
||||||
|
3. **UI Визарда (Шаг 4):**
|
||||||
|
- Добавлена кнопка быстрого копирования `seedCommitment` в буфер обмена для публикации организатором в комментариях к посту до запуска розыгрыша.
|
||||||
|
|
||||||
|
4. **Документация (`README.md`, `docs/ARCHITECTURE.md`):**
|
||||||
|
- Честно зафиксированы границы проверяемости и компромисс защиты приватности (PII).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Границы публичной проверяемости (Provably Fair Scope & Privacy Compromise)
|
||||||
|
|
||||||
|
### Что может независимо проверить любой внешний наблюдатель:
|
||||||
|
1. **Защита от Seed Grinding:** совпадение $\text{SHA256}(\text{seed}) == \text{SeedCommitment}$ гарантирует, что случайное число было сгенерировано и зафиксировано на этапе создания слепка до жеребьевки, а не подбиралось организатором под конкретных победителей.
|
||||||
|
2. **Воспроизводимость алгоритма:** соответствие вычислений стандарту `HMAC_SHA256_FY_V1`.
|
||||||
|
3. **Целостность доказательства:** совпадение `deterministicProofHash` и `auditEventHash`.
|
||||||
|
|
||||||
|
### Что остаётся непроверяемым внешним наблюдателем (и почему):
|
||||||
|
1. **Вычисление `participantsSnapshotHash` с нуля:** без полного списка участников сторонний наблюдатель не может самостоятельно пересчитать хеш слепка участников. Список участников намеренно не отдаётся анонимам ради защиты персональных данных третьих лиц (PII).
|
||||||
|
2. **Внешний якорь времени:** доказательство фиксируется в базе данных Randomayzer. Внешний децентрализованный якорь (блокчейн, drand beacon, RFC 3161) на текущем этапе отсутствует.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Modified Files
|
||||||
|
|
||||||
|
| File | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `src/app/api/giveaways/[id]/public/route.ts` | API Route (NEW) | Публичный маршрут с отдачей данных розыгрыша без PII и с защитой сида до жеребьевки. |
|
||||||
|
| `src/app/giveaways/[id]/page.tsx` | UI | Перевод страницы на `/api/giveaways/[id]/public` и отображение `seedCommitment`. |
|
||||||
|
| `src/app/giveaways/new/page.tsx` | UI | Кнопка копирования `seedCommitment` на шаге 4 визарда. |
|
||||||
|
| `docs/ARCHITECTURE.md` | Docs | Обновлен раздел механизма честности и границ проверяемости. |
|
||||||
|
| `README.md` | Docs | Описаны возможности публичной проверки и Seed Pre-Commitment. |
|
||||||
|
| `tests/public-verification.test.ts` | Tests (NEW) | Тесты публичного эндпоинта (5 тестов): доступ анонимов, маскирование seed, проверка commitment, защита 401 на приватном маршруте, отсутствие PII. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Verification Evidence & Test Gate
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx prisma generate -> EXIT 0 (Prisma Client v5.22.0)
|
||||||
|
npx tsc --noEmit -> EXIT 0 (0 ошибок типизации)
|
||||||
|
npm test -> EXIT 0 (54 тестовых файла, 316 тестов прошли успешно)
|
||||||
|
npm run lint -> EXIT 0 (0 ошибок, 6 warnings на no-img-element)
|
||||||
|
npm run build -> EXIT 0 (Все 17 маршрутов скомпилированы успешно)
|
||||||
|
npm audit --omit=dev -> EXIT 0 (0 vulnerabilities)
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Task 06: Публичная проверяемость розыгрыша
|
||||||
|
|
||||||
|
**Assigned to:** Antigravity (Implementation Orchestrator)
|
||||||
|
**Priority:** MEDIUM (product claim alignment)
|
||||||
|
**Date:** 2026-08-21
|
||||||
|
**Base SHA:** `1a27a10847fe510f0ed0f128087271f78a489c7b`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
1. Implement public read-only giveaway result endpoint `GET /api/giveaways/[id]/public`:
|
||||||
|
- Publicly accessible without session.
|
||||||
|
- Bounded by anonymous rate limiting (`expensiveApiRateLimiter.assertAllowed('giveaway-public-get:' + clientIp + ':' + id)`).
|
||||||
|
- Exposes safe public information:
|
||||||
|
* Post metadata & snapshot `filterRules`
|
||||||
|
* `participantsSnapshotHash`, `conditionsHash`, `algorithmVersion`
|
||||||
|
* `seedCommitment` — both BEFORE and AFTER the draw
|
||||||
|
* After `DRAWN`: `seed` (revealed only once finalized), `deterministicProofHash`, `auditEventHash`, winners & reserve winners (public winner names & IDs)
|
||||||
|
* Before `DRAWN`: `seed === null` (strictly masked)
|
||||||
|
* Zero private PII: full eligible/excluded participants list is omitted to protect third-party privacy
|
||||||
|
* Zero credential/token/internal organizer metadata
|
||||||
|
2. Update public giveaway view page `src/app/giveaways/[id]/page.tsx`:
|
||||||
|
- Works seamlessly for unauthenticated visitors by fetching from `/api/giveaways/[id]/public`.
|
||||||
|
- Displays post information, winners, seed pre-commitment SHA-256, proof hash, and mathematical verification state.
|
||||||
|
3. Update `README.md` and `docs/ARCHITECTURE.md` to document the Provably Fair model honestly and explicitly:
|
||||||
|
- What is independently verifiable by external observers (seed pre-commitment binding, draw execution reproducibility from snapshot hash + seed).
|
||||||
|
- What remains unverifiable externally without raw PII (external observers cannot re-compute `participantsSnapshotHash` without the full raw participant list, and the server DB holds the proof without external decentralized timestamping/blockchain anchor).
|
||||||
|
4. Create test suite `tests/public-verification.test.ts`:
|
||||||
|
- Anonymous user retrieves public results for `DRAWN` giveaway.
|
||||||
|
- Anonymous user does not receive `seed` on non-`DRAWN` giveaway (`seed === null`).
|
||||||
|
- `seedCommitment` is visible before draw and equals `sha256(seed)` after draw.
|
||||||
|
- Anonymous user still receives `401 Unauthorized` on private `GET /api/giveaways/[id]`.
|
||||||
|
- Zero private participant PII or tokens in public response.
|
||||||
|
5. Verification gate & report in `agents/antigravity/done/TASK-2026-08-21-06-public-verification.md`.
|
||||||
|
|
@ -48,12 +48,34 @@ graph TD
|
||||||
|
|
||||||
## 3. Механизм честности и доказуемости (Provably Fair)
|
## 3. Механизм честности и доказуемости (Provably Fair)
|
||||||
|
|
||||||
Каждый розыгрыш формирует криптографический аудит-след:
|
Каждый розыгрыш формирует криптографический аудит-след на базе алгоритма `HMAC_SHA256_FY_V1`:
|
||||||
1. **Снапшот участников**: Список прошедших фильтрацию (eligible) участников сортируется по `platformUserId` и хешируется через SHA-256:
|
|
||||||
$$\text{ParticipantsHash} = \text{SHA256}(\text{JSON}(\text{sortedEligibleParticipants}))$$
|
1. **Снапшот участников**: Список прошедших фильтрацию (eligible) участников сортируется по `platformUserId` и канонически сериализуется:
|
||||||
2. **Seed розыгрыша**: Пользовательский или сгенерированный криптографически стойкий seed.
|
$$\text{ParticipantsSnapshotHash} = \text{SHA256}(\text{canonicalStringify}(\text{sortedEligibleParticipants}))$$
|
||||||
3. **Детерминированный выбор**:
|
2. **Seed Pre-Commitment (Защита от Seed Grinding)**:
|
||||||
- Для каждого шага выбора индекса вычисляется:
|
- В момент фиксации слепка (`SNAPSHOT_LOCKED`) сервер генерирует CSPRNG seed и публикует его SHA-256 обязательство:
|
||||||
$$\text{Hash}_i = \text{HMAC-SHA256}(\text{seed} + ":" + i, \text{ParticipantsHash})$$
|
$$\text{SeedCommitment} = \text{SHA256}(\text{seed})$$
|
||||||
- Индекс победителя определяется детерминированно из полученного хеша.
|
- До момента проведения жеребьевки сам `seed` строго скрыт (`seed: null`), но `seedCommitment` доступен публично. Организатор может зафиксировать его публично (например, в комментарии к конкурсному посту VK) до розыгрыша.
|
||||||
4. **Результат**: Зная `ParticipantsHash` и `seed`, любой внешний наблюдатель может воспроизвести выбор и убедиться в честности результата на 100%.
|
3. **Детерминированный выбор (`HMAC_SHA256_FY_V1`)**:
|
||||||
|
- Выборка осуществляется с помощью несмещенного сэмплинга Фишера-Йетса (Fisher-Yates) поверх потока псевдослучайных байт HMAC-SHA256:
|
||||||
|
$$\text{ByteStream} = \text{HMAC-SHA256}(\text{key} = \text{seed}, \text{data} = \text{ParticipantsSnapshotHash} \parallel \text{ConditionsHash} \parallel \text{blockIndex})$$
|
||||||
|
- Позиции победителей и резерва рассчитываются детерминированно.
|
||||||
|
4. **Публичное раскрытие и аудит**:
|
||||||
|
- После перевода розыгрыша в статус `DRAWN` сервер раскрывает `seed`.
|
||||||
|
- Любой участник может проверить:
|
||||||
|
1. $\text{SHA256}(\text{seed}) == \text{SeedCommitment}$ (гарантия того, что seed не подбирался под желаемого победителя);
|
||||||
|
2. Воспроизведение результатов выборки при наличии слепка;
|
||||||
|
3. Неизменность `deterministicProofHash` и `auditEventHash`.
|
||||||
|
|
||||||
|
### 3.1. Границы публичной проверяемости и защита приватности (PII Compromise)
|
||||||
|
|
||||||
|
- **Что проверяется внешним наблюдателем:**
|
||||||
|
- Корректность раскрытия seed относительно опубликованного pre-commitment.
|
||||||
|
- Математическая повторяемость алгоритма.
|
||||||
|
- Совпадение хешей доказательства (`deterministicProofHash`).
|
||||||
|
- **Что остаётся приватным:**
|
||||||
|
- Полный список участников и их персональные данные (PII) **не отдаются анонимным пользователям** в целях соблюдения требований защиты данных третьих лиц. Публикуются только победители и хеш слепка `participantsSnapshotHash`.
|
||||||
|
- Внешний наблюдатель без исходного списка участников не может самостоятельно с нуля пересчитать `participantsSnapshotHash`.
|
||||||
|
- **Архитектурный статус доверия:**
|
||||||
|
- Доказательство формируется и проверяется на сервере Randomayzer на основе зафиксированного в БД слепка. Децентрализованный внешний якорь (блокчейн, drand, RFC 3161) на текущем этапе не используется.
|
||||||
|
|
||||||
|
|
|
||||||
109
src/app/api/giveaways/[id]/public/route.ts
Normal file
109
src/app/api/giveaways/[id]/public/route.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { GiveawayStore } from '@/lib/giveaway-store';
|
||||||
|
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
|
||||||
|
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
import { computeSeedCommitment } from '@/core/randomizer/hasher';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> | { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const clientIp = resolveClientIp(req);
|
||||||
|
|
||||||
|
// Anonymous rate limiter
|
||||||
|
expensiveApiRateLimiter.assertAllowed(`giveaway-public-get:${clientIp}:${id}`);
|
||||||
|
|
||||||
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
|
if (!giveaway) {
|
||||||
|
throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDrawn = giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED';
|
||||||
|
|
||||||
|
// Calculate or retrieve seed commitment (accessible before and after draw)
|
||||||
|
const seedCommitment = giveaway.seedCommitment || (giveaway.seed ? computeSeedCommitment(giveaway.seed) : null);
|
||||||
|
|
||||||
|
// Revealed seed: strictly null before finalized draw, revealed once drawn
|
||||||
|
const seed = isDrawn ? giveaway.seed : null;
|
||||||
|
|
||||||
|
const publicGiveaway = {
|
||||||
|
id: giveaway.id,
|
||||||
|
status: giveaway.status,
|
||||||
|
title: giveaway.title,
|
||||||
|
description: giveaway.description,
|
||||||
|
platform: giveaway.platform,
|
||||||
|
sourceUrl: giveaway.sourceUrl,
|
||||||
|
post: {
|
||||||
|
platform: giveaway.platform,
|
||||||
|
ownerId: giveaway.platformOwnerId,
|
||||||
|
postId: giveaway.platformPostId,
|
||||||
|
title: giveaway.title,
|
||||||
|
imageUrl: giveaway.postImageUrl,
|
||||||
|
likesCount: giveaway.postLikesCount,
|
||||||
|
commentsCount: giveaway.postCommentsCount,
|
||||||
|
repostsCount: giveaway.postRepostsCount,
|
||||||
|
},
|
||||||
|
postImageUrl: giveaway.postImageUrl,
|
||||||
|
filterRules: giveaway.latestSnapshot?.filterRulesSnapshot || giveaway.filterRules,
|
||||||
|
winnersCount: giveaway.winnersCount,
|
||||||
|
reserveWinnersCount: giveaway.reserveWinnersCount,
|
||||||
|
seedCommitment,
|
||||||
|
seed,
|
||||||
|
drawnAt: giveaway.drawnAt,
|
||||||
|
createdAt: giveaway.createdAt,
|
||||||
|
updatedAt: giveaway.updatedAt,
|
||||||
|
latestSnapshot: giveaway.latestSnapshot ? {
|
||||||
|
version: giveaway.latestSnapshot.version,
|
||||||
|
createdAt: giveaway.latestSnapshot.createdAt,
|
||||||
|
participantCount: giveaway.latestSnapshot.participantCount,
|
||||||
|
participantsSnapshotHash: giveaway.latestSnapshot.participantsSnapshotHash,
|
||||||
|
conditionsHash: giveaway.latestSnapshot.conditionsHash,
|
||||||
|
} : null,
|
||||||
|
drawResult: giveaway.drawResult ? {
|
||||||
|
drawId: giveaway.drawResult.drawId,
|
||||||
|
algorithmVersion: giveaway.drawResult.algorithmVersion,
|
||||||
|
totalEligibleCount: giveaway.drawResult.totalEligibleCount,
|
||||||
|
totalLoadedCount: giveaway.drawResult.totalLoadedCount,
|
||||||
|
seedUsed: giveaway.drawResult.seedUsed,
|
||||||
|
snapshotId: giveaway.drawResult.snapshotId,
|
||||||
|
drawnAt: giveaway.drawResult.drawnAt,
|
||||||
|
deterministicProofHash: giveaway.drawResult.deterministicProofHash,
|
||||||
|
auditEventHash: giveaway.drawResult.auditEventHash,
|
||||||
|
participantsSnapshotHash: giveaway.drawResult.participantsSnapshotHash,
|
||||||
|
conditionsHash: giveaway.drawResult.conditionsHash,
|
||||||
|
winners: giveaway.drawResult.winners.map(w => ({
|
||||||
|
position: w.position,
|
||||||
|
participant: {
|
||||||
|
platformUserId: w.participant.platformUserId,
|
||||||
|
firstName: w.participant.firstName,
|
||||||
|
lastName: w.participant.lastName,
|
||||||
|
avatarUrl: w.participant.avatarUrl,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
reserveWinners: giveaway.drawResult.reserveWinners.map(w => ({
|
||||||
|
position: w.position,
|
||||||
|
participant: {
|
||||||
|
platformUserId: w.participant.platformUserId,
|
||||||
|
firstName: w.participant.firstName,
|
||||||
|
lastName: w.participant.lastName,
|
||||||
|
avatarUrl: w.participant.avatarUrl,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
winnerIds: giveaway.drawResult.winnerIds,
|
||||||
|
reserveWinnerIds: giveaway.drawResult.reserveWinnerIds,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
giveaway: publicGiveaway,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,9 +33,9 @@ export default function GiveawayDetailPage() {
|
||||||
const fetchGw = async () => {
|
const fetchGw = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch(`/api/giveaways/${id}`);
|
const res = await fetch(`/api/giveaways/${id}/public`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Розыгрыш не найден');
|
if (!res.ok) throw new Error(data.error?.message || data.error || 'Розыгрыш не найден');
|
||||||
setGiveaway(data.giveaway);
|
setGiveaway(data.giveaway);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|
@ -269,9 +269,15 @@ export default function GiveawayDetailPage() {
|
||||||
<p className="font-mono text-amber-400 break-all">{drawResult.algorithmVersion}</p>
|
<p className="font-mono text-amber-400 break-all">{drawResult.algorithmVersion}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||||
<span className="text-slate-400">Seed:</span>
|
<span className="text-slate-400">Seed (Раскрыт):</span>
|
||||||
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
|
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{giveaway.seedCommitment && (
|
||||||
|
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||||
|
<span className="text-slate-400">Seed Commitment (SHA-256):</span>
|
||||||
|
<p className="font-mono text-emerald-400 break-all">{giveaway.seedCommitment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||||
<span className="text-slate-400">Snapshot Hash:</span>
|
<span className="text-slate-400">Snapshot Hash:</span>
|
||||||
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
|
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export default function NewGiveawayWizardPage() {
|
||||||
const [participants, setParticipants] = useState<FilteredParticipant[]>([]);
|
const [participants, setParticipants] = useState<FilteredParticipant[]>([]);
|
||||||
const [lockedSnapshot, setLockedSnapshot] = useState<ParticipantSnapshotData | null>(null);
|
const [lockedSnapshot, setLockedSnapshot] = useState<ParticipantSnapshotData | null>(null);
|
||||||
const [seedCommitment, setSeedCommitment] = useState<string | null>(null);
|
const [seedCommitment, setSeedCommitment] = useState<string | null>(null);
|
||||||
|
const [copiedCommitment, setCopiedCommitment] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [pageSize] = useState(50);
|
const [pageSize] = useState(50);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
@ -852,9 +853,24 @@ export default function NewGiveawayWizardPage() {
|
||||||
Случайный криптографический seed зафиксирован на сервере в момент блокировки слепка. Ручной ввод отключен для математической гарантии честности и защиты от seed grinding.
|
Случайный криптографический seed зафиксирован на сервере в момент блокировки слепка. Ручной ввод отключен для математической гарантии честности и защиты от seed grinding.
|
||||||
</p>
|
</p>
|
||||||
{seedCommitment && (
|
{seedCommitment && (
|
||||||
<div className="p-2.5 rounded-lg bg-slate-900 border border-slate-800 text-[11px] font-mono flex items-center gap-2 overflow-x-auto">
|
<div className="p-3 rounded-xl bg-slate-900 border border-slate-800 text-[11px] font-mono flex items-center justify-between gap-2">
|
||||||
<span className="text-slate-500 shrink-0">Commitment SHA-256:</span>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-emerald-400 truncate">{seedCommitment}</span>
|
<span className="text-slate-500 shrink-0">Commitment SHA-256:</span>
|
||||||
|
<span className="text-emerald-400 truncate">{seedCommitment}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(seedCommitment);
|
||||||
|
setCopiedCommitment(true);
|
||||||
|
setTimeout(() => setCopiedCommitment(false), 2000);
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors shrink-0 flex items-center gap-1 text-[10px]"
|
||||||
|
title="Скопировать обязательство для публикации до жеребьевки"
|
||||||
|
>
|
||||||
|
{copiedCommitment ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{copiedCommitment ? 'Скопировано' : 'Копировать'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
182
tests/public-verification.test.ts
Normal file
182
tests/public-verification.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { GET as publicGiveawayGet } from '../src/app/api/giveaways/[id]/public/route';
|
||||||
|
import { GET as privateGiveawayGet } from '../src/app/api/giveaways/[id]/route';
|
||||||
|
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
|
||||||
|
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
|
||||||
|
import { GiveawayStore } from '../src/lib/giveaway-store';
|
||||||
|
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||||
|
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
||||||
|
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||||
|
import { FilteredParticipant } from '../src/core/types/participant';
|
||||||
|
import { expensiveApiRateLimiter } from '../src/lib/rate-limiter';
|
||||||
|
|
||||||
|
describe('Task 06: Public Giveaway Verification Endpoint (GET /api/giveaways/[id]/public)', () => {
|
||||||
|
const organizerUser = { id: 'usr_pub_verify_org', vkUserId: '555444' };
|
||||||
|
let sessionCookie: string;
|
||||||
|
|
||||||
|
const testParticipants: FilteredParticipant[] = Array.from({ length: 25 }, (_, i) => ({
|
||||||
|
platformUserId: `${4000 + i}`,
|
||||||
|
firstName: `User${i}`,
|
||||||
|
lastName: `Public${i}`,
|
||||||
|
source: 'LIKES',
|
||||||
|
liked: true,
|
||||||
|
commented: false,
|
||||||
|
commentsCount: 0,
|
||||||
|
reposted: false,
|
||||||
|
subscribed: true,
|
||||||
|
eligible: true,
|
||||||
|
exclusionReason: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||||
|
defaultSessionStore.clear();
|
||||||
|
expensiveApiRateLimiter.reset();
|
||||||
|
|
||||||
|
const sessionId = await defaultSessionStore.createSession(organizerUser);
|
||||||
|
sessionCookie = `${SESSION_COOKIE_NAME}=${sessionId}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createLockedGiveaway() {
|
||||||
|
const gw = await GiveawayStore.create({
|
||||||
|
sourceUrl: 'https://vk.com/wall-55667788_999',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-55667788',
|
||||||
|
postId: '999',
|
||||||
|
sourceUrl: 'https://vk.com/wall-55667788_999',
|
||||||
|
title: 'Public Verification Test Post',
|
||||||
|
text: 'Provably fair giveaway description',
|
||||||
|
likesCount: 25,
|
||||||
|
commentsCount: 0,
|
||||||
|
repostsCount: 0,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
organizerId: organizerUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await GiveawayStore.updateParticipants(gw.id, testParticipants);
|
||||||
|
|
||||||
|
const snapReq = new NextRequest(`http://localhost:3000/api/giveaways/${gw.id}/snapshot`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: sessionCookie },
|
||||||
|
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
|
||||||
|
});
|
||||||
|
const snapRes = await snapshotPost(snapReq, { params: { id: gw.id } });
|
||||||
|
const snapData = await snapRes.json();
|
||||||
|
|
||||||
|
return { giveawayId: gw.id, seedCommitment: snapData.seedCommitment, snapshot: snapData.snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 1: Anonymous Access Before Draw (Masked Seed, Exposed Commitment) ───
|
||||||
|
it('anonymous visitor before draw sees seedCommitment and snapshot hash, but seed is strictly null', async () => {
|
||||||
|
const { giveawayId, seedCommitment } = await createLockedGiveaway();
|
||||||
|
|
||||||
|
const req = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}/public`);
|
||||||
|
const res = await publicGiveawayGet(req, { params: { id: giveawayId } });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
|
||||||
|
const gw = data.giveaway;
|
||||||
|
expect(gw.id).toBe(giveawayId);
|
||||||
|
expect(gw.status).toBe('SNAPSHOT_LOCKED');
|
||||||
|
expect(gw.title).toBe('Public Verification Test Post');
|
||||||
|
expect(gw.seedCommitment).toBe(seedCommitment);
|
||||||
|
expect(gw.seed).toBeNull(); // Strictly masked before draw!
|
||||||
|
expect(gw.latestSnapshot.participantsSnapshotHash).toBeDefined();
|
||||||
|
expect(gw.latestSnapshot.conditionsHash).toBeDefined();
|
||||||
|
expect(gw.drawResult).toBeNull();
|
||||||
|
|
||||||
|
// Verify raw PII protection
|
||||||
|
expect((gw as any).participants).toBeUndefined();
|
||||||
|
expect((gw as any).eligibleParticipants).toBeUndefined();
|
||||||
|
expect((gw as any).organizerId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test 2: Anonymous Access After Draw (Revealed Seed, Proof & Winners) ─────
|
||||||
|
it('anonymous visitor after draw sees revealed seed, deterministic proof, and winner public profiles', async () => {
|
||||||
|
const { giveawayId, seedCommitment } = await createLockedGiveaway();
|
||||||
|
|
||||||
|
// Execute draw as organizer
|
||||||
|
const drawReq = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}/draw`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: sessionCookie },
|
||||||
|
body: JSON.stringify({ winnersCount: 2, reserveWinnersCount: 1 }),
|
||||||
|
});
|
||||||
|
const drawRes = await drawPost(drawReq, { params: { id: giveawayId } });
|
||||||
|
expect(drawRes.status).toBe(200);
|
||||||
|
|
||||||
|
// Fetch public giveaway
|
||||||
|
const req = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}/public`);
|
||||||
|
const res = await publicGiveawayGet(req, { params: { id: giveawayId } });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
const gw = data.giveaway;
|
||||||
|
|
||||||
|
expect(gw.status).toBe('DRAWN');
|
||||||
|
expect(gw.seedCommitment).toBe(seedCommitment);
|
||||||
|
expect(gw.seed).toBeDefined();
|
||||||
|
expect(typeof gw.seed).toBe('string');
|
||||||
|
|
||||||
|
// SHA-256 of revealed seed MUST equal the pre-committed hash
|
||||||
|
const computedHash = createHash('sha256').update(gw.seed).digest('hex');
|
||||||
|
expect(computedHash).toBe(seedCommitment);
|
||||||
|
|
||||||
|
// Draw result details
|
||||||
|
expect(gw.drawResult).toBeDefined();
|
||||||
|
expect(gw.drawResult.algorithmVersion).toBe('HMAC_SHA256_FY_V1');
|
||||||
|
expect(gw.drawResult.deterministicProofHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(gw.drawResult.auditEventHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(gw.drawResult.winners).toHaveLength(2);
|
||||||
|
expect(gw.drawResult.reserveWinners).toHaveLength(1);
|
||||||
|
|
||||||
|
// Winner profiles contain only safe public fields
|
||||||
|
const winner1 = gw.drawResult.winners[0];
|
||||||
|
expect(winner1.position).toBe(1);
|
||||||
|
expect(winner1.participant.platformUserId).toBeDefined();
|
||||||
|
expect(winner1.participant.firstName).toBeDefined();
|
||||||
|
expect(winner1.participant.lastName).toBeDefined();
|
||||||
|
expect((winner1.participant as any).encryptedToken).toBeUndefined();
|
||||||
|
|
||||||
|
// Raw participant list is still omitted
|
||||||
|
expect((gw as any).participants).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test 3: Private Giveaway Detail Route Remains Protected (401) ───────────
|
||||||
|
it('anonymous request to private GET /api/giveaways/[id] returns 401 Unauthorized', async () => {
|
||||||
|
const { giveawayId } = await createLockedGiveaway();
|
||||||
|
|
||||||
|
const req = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}`);
|
||||||
|
const res = await privateGiveawayGet(req, { params: { id: giveawayId } });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test 4: Non-Existent Giveaway Returns 404 ────────────────────────────────
|
||||||
|
it('request for non-existent giveaway returns 404 Not Found', async () => {
|
||||||
|
const req = new NextRequest('http://localhost:3000/api/giveaways/non_existent_123/public');
|
||||||
|
const res = await publicGiveawayGet(req, { params: { id: 'non_existent_123' } });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test 5: Rate Limiting on Public Endpoint ─────────────────────────────────
|
||||||
|
it('public endpoint enforces rate limiting per client IP', async () => {
|
||||||
|
const { giveawayId } = await createLockedGiveaway();
|
||||||
|
|
||||||
|
// Exhaust expensive rate limiter (15 requests)
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
expensiveApiRateLimiter.check(`giveaway-public-get:direct-client:${giveawayId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = new NextRequest(`http://localhost:3000/api/giveaways/${giveawayId}/public`);
|
||||||
|
const res = await publicGiveawayGet(req, { params: { id: giveawayId } });
|
||||||
|
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue