feat(core): Phase 1.1 hardening - Prisma persistence, FSM, immutable snapshots, unbiased HMAC_SHA256_FY_V1 randomizer, and audit proof

This commit is contained in:
Ochenstarik 2026-08-17 23:44:51 +07:00
parent 02920a8743
commit 0fb5fe3f8d
31 changed files with 6554 additions and 480 deletions

3
.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

4662
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,8 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.15",
"postcss": "^8.4.47",
"prisma": "^5.20.0",
"tailwindcss": "^3.4.13",

View file

@ -17,7 +17,9 @@ enum GiveawayStatus {
DRAFT
FETCHING
READY
COMPLETED
SNAPSHOT_LOCKED
DRAWN
PUBLISHED
CANCELLED
}
@ -29,27 +31,28 @@ enum ParticipantSource {
}
model Giveaway {
id String @id @default(cuid())
platform Platform @default(VK)
id String @id @default(cuid())
platform Platform @default(VK)
sourceUrl String
platformOwnerId String
platformPostId String
title String
description String?
postImageUrl String?
postLikesCount Int @default(0)
postCommentsCount Int @default(0)
postRepostsCount Int @default(0)
status GiveawayStatus @default(DRAFT)
filterRules Json // FilterRules object
winnersCount Int @default(1)
reserveWinnersCount Int @default(0)
postLikesCount Int @default(0)
postCommentsCount Int @default(0)
postRepostsCount Int @default(0)
status GiveawayStatus @default(DRAFT)
filterRules Json // FilterRules object
winnersCount Int @default(1)
reserveWinnersCount Int @default(0)
seed String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
drawnAt DateTime?
participants Participant[]
snapshots ParticipantSnapshot[]
drawResult DrawResult?
auditRecord AuditRecord?
@ -79,28 +82,57 @@ model Participant {
@@index([giveawayId, eligible])
}
model ParticipantSnapshot {
id String @id @default(cuid())
giveawayId String
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
version Int @default(1)
createdAt DateTime @default(now())
eligibleParticipants Json // Canonical JSON array of FilteredParticipant
participantCount Int
participantsSnapshotHash String
conditionsHash String
drawResult DrawResult?
auditRecord AuditRecord?
@@unique([giveawayId, version])
@@index([giveawayId])
}
model DrawResult {
id String @id @default(cuid())
giveawayId String @unique
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
winners Json // Array of Winner entities
reserveWinners Json // Array of Winner entities
id String @id @default(cuid())
giveawayId String @unique
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
snapshotId String @unique
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
winners Json // Array of Winner entities
reserveWinners Json // Array of Winner entities
winnerIds Json // string[]
reserveWinnerIds Json // string[]
totalEligibleCount Int
totalLoadedCount Int
seedUsed String
algorithm String @default("HMAC-SHA256-FISHER-YATES")
drawnAt DateTime @default(now())
algorithmVersion String @default("HMAC_SHA256_FY_V1")
auditHash String
drawnAt DateTime @default(now())
}
model AuditRecord {
id String @id @default(cuid())
giveawayId String @unique
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
participantsSnapshotHash String
id String @id @default(cuid())
giveawayId String @unique
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
snapshotId String @unique
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
algorithmVersion String @default("HMAC_SHA256_FY_V1")
seed String
algorithm String @default("HMAC-SHA256-FISHER-YATES")
filterRulesSnapshot Json
winnersSnapshot Json
verifiedAt DateTime @default(now())
verificationSignature String
participantsSnapshotHash String
conditionsHash String
auditHash String
winnerIds Json // string[]
reserveWinnerIds Json // string[]
eligibleCount Int
drawId String
drawnAt DateTime @default(now())
verifiedAt DateTime @default(now())
}

View file

@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store';
import { executeDeterministicDraw } from '@/core/randomizer/deterministic';
import { generateRandomSeed } from '@/core/randomizer/hasher';
import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic';
import { generateCryptoSecureSeed } from '@/core/randomizer/hasher';
import { GiveawayFSM } from '@/core/fsm/giveaway-fsm';
export async function POST(
req: NextRequest,
@ -16,22 +17,38 @@ export async function POST(
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
}
const eligibleParticipants = giveaway.participants.filter(p => p.eligible);
if (eligibleParticipants.length === 0) {
return NextResponse.json({
error: 'Нет допущенных участников для проведения розыгрыша'
// 1. Guard check with FSM
if (giveaway.status === 'DRAWN') {
return NextResponse.json({
error: 'Розыгрыш уже проведен. Повторный запуск строго запрещен.',
}, { status: 400 });
}
// 2. Fetch locked snapshot (or lock current eligible if ready)
let snapshot = await GiveawayStore.getLatestSnapshot(id);
if (!snapshot) {
const eligible = giveaway.participants.filter(p => p.eligible);
if (eligible.length === 0) {
return NextResponse.json({
error: 'Нет допущенных участников для создания слепка и розыгрыша'
}, { status: 400 });
}
snapshot = await GiveawayStore.createAndLockSnapshot(id, eligible, giveaway.filterRules);
}
// Validate status after snapshot lock
GiveawayFSM.assertCanDraw('SNAPSHOT_LOCKED');
const winnersCount = body.winnersCount || giveaway.winnersCount || 1;
const reserveWinnersCount = body.reserveWinnersCount ?? giveaway.reserveWinnersCount ?? 0;
const seed = body.seed?.trim() || giveaway.seed || generateRandomSeed();
// Seed must be generated with CSPRNG if not provided
const seed = body.seed?.trim() || giveaway.seed || generateCryptoSecureSeed();
// Execute provably fair draw
const drawResult = executeDeterministicDraw({
// 3. Execute Provably Fair Randomizer V1
const drawResult = executeDeterministicDrawV1({
giveawayId: id,
eligibleParticipants,
snapshot,
totalLoadedCount: giveaway.participants.length,
winnersCount,
reserveWinnersCount,
@ -39,8 +56,8 @@ export async function POST(
filterRules: giveaway.filterRules,
});
// Persist result
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, drawResult);
// 4. Persist DrawResult and AuditRecord atomically
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult);
return NextResponse.json({
success: true,

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store';
import { ProviderRegistry } from '@/providers/registry';
import { applyFilterRules } from '@/core/filtering/filter-engine';
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher';
export async function POST(
req: NextRequest,
@ -19,7 +19,7 @@ export async function POST(
const rules = body.filterRules || giveaway.filterRules;
const provider = ProviderRegistry.getProvider(giveaway.platform);
// Fetch raw participants from provider
// 1. Fetch raw participants
const rawParticipants = await provider.fetchParticipants({
ownerId: giveaway.platformOwnerId,
postId: giveaway.platformPostId,
@ -27,13 +27,17 @@ export async function POST(
includeLikes: true,
includeComments: rules.requireComment,
includeReposts: rules.requireRepost,
checkSubscription: rules.requireSubscription,
});
// Apply filtering engine
const filterResult = applyFilterRules(rawParticipants, rules);
// 2. Run enrichment pipeline (subscription check + filter engine)
const filterResult = await executeParticipantPipeline({
rawParticipants,
rules,
provider,
ownerId: giveaway.platformOwnerId,
});
// Update participants in store
// 3. Save participants into persistent database
await GiveawayStore.updateParticipants(id, filterResult.allParticipants);
return NextResponse.json({

View file

@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store';
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
const body = await req.json().catch(() => ({}));
const giveaway = await GiveawayStore.getById(id);
if (!giveaway) {
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
}
const eligibleParticipants = giveaway.participants.filter(p => p.eligible);
if (eligibleParticipants.length === 0) {
return NextResponse.json({
error: 'Нельзя создать слепок с 0 допущенными участниками'
}, { status: 400 });
}
const rules = body.filterRules || giveaway.filterRules;
const snapshot = await GiveawayStore.createAndLockSnapshot(
id,
eligibleParticipants,
rules
);
return NextResponse.json({
success: true,
snapshot,
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -8,13 +8,11 @@ import {
ShieldCheck,
Trophy,
ExternalLink,
CheckCircle2,
Copy,
Check,
Users,
Calendar,
Sparkles,
RefreshCw
RefreshCw,
Lock
} from 'lucide-react';
import { StoredGiveaway } from '@/lib/giveaway-store';
@ -66,6 +64,7 @@ export default function GiveawayDetailPage() {
}
const drawResult = giveaway.drawResult;
const snapshot = giveaway.latestSnapshot;
return (
<div className="max-w-4xl mx-auto space-y-6">
@ -82,15 +81,9 @@ export default function GiveawayDetailPage() {
<span className="text-xs px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-medium">
VKontakte
</span>
{giveaway.status === 'COMPLETED' ? (
<span className="text-xs px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-medium">
Завершен
</span>
) : (
<span className="text-xs px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-400 border border-amber-500/20 font-medium">
В процессе
</span>
)}
<span className="text-xs px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-medium font-mono">
{giveaway.status}
</span>
</div>
</div>
@ -181,7 +174,18 @@ export default function GiveawayDetailPage() {
</div>
<button
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(drawResult, null, 2));
navigator.clipboard.writeText(JSON.stringify({
giveawayId: giveaway.id,
snapshotId: drawResult.snapshotId,
algorithmVersion: drawResult.algorithmVersion,
seed: drawResult.seedUsed,
participantsSnapshotHash: drawResult.participantsSnapshotHash,
conditionsHash: drawResult.conditionsHash,
auditHash: drawResult.auditHash,
winnerIds: drawResult.winnerIds,
reserveWinnerIds: drawResult.reserveWinnerIds,
drawnAt: drawResult.drawnAt,
}, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
@ -194,16 +198,24 @@ export default function GiveawayDetailPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<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">Snapshot ID:</span>
<p className="font-mono text-slate-300 break-all">{drawResult.snapshotId}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Алгоритм:</span>
<p className="font-mono text-amber-400 break-all">{drawResult.algorithmVersion}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Seed:</span>
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Snapshot Hash (SHA-256):</span>
<span className="text-slate-400">Snapshot Hash:</span>
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2">
<span className="text-slate-400">Verification Signature:</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.verificationSignature}</p>
<span className="text-slate-400">Канонический auditHash:</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p>
</div>
</div>
</div>

View file

@ -2,7 +2,6 @@
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
Sparkles,
@ -17,19 +16,17 @@ import {
RefreshCw,
Shuffle,
Copy,
ExternalLink,
Info,
Check,
Award,
AlertCircle
ExternalLink,
Info,
Check,
AlertCircle,
Lock
} from 'lucide-react';
import { FilterRules, DEFAULT_FILTER_RULES, PostMetadata } from '@/core/types/giveaway';
import { FilteredParticipant, Winner } from '@/core/types/participant';
import { DrawExecutionResult } from '@/core/types/audit';
import { DrawExecutionResult, ParticipantSnapshotData } from '@/core/types/audit';
export default function NewGiveawayWizardPage() {
const router = useRouter();
// Wizard state
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
@ -44,10 +41,12 @@ export default function NewGiveawayWizardPage() {
const [rules, setRules] = useState<FilterRules>({ ...DEFAULT_FILTER_RULES });
const [blacklistInput, setBlacklistInput] = useState('');
// Step 3: Participants
// Step 3: Participants & Snapshot
const [loadingParticipants, setLoadingParticipants] = useState(false);
const [participants, setParticipants] = useState<FilteredParticipant[]>([]);
const [participantTab, setParticipantTab] = useState<'all' | 'eligible' | 'excluded'>('eligible');
const [lockingSnapshot, setLockingSnapshot] = useState(false);
const [lockedSnapshot, setLockedSnapshot] = useState<ParticipantSnapshotData | null>(null);
// Step 4: Draw parameters
const [winnersCount, setWinnersCount] = useState<number>(1);
@ -79,7 +78,6 @@ export default function NewGiveawayWizardPage() {
setPostData(data.post);
// Create initial draft giveaway
const createRes = await fetch('/api/giveaways', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -101,7 +99,7 @@ export default function NewGiveawayWizardPage() {
}
};
// Step 2 handler: Fetch Participants
// Step 2 handler: Fetch & Enrich Participants
const handleFetchParticipants = async () => {
if (!createdGiveawayId) return;
setLoadingParticipants(true);
@ -133,6 +131,30 @@ export default function NewGiveawayWizardPage() {
}
};
// Step 3 handler: Lock Immutable Snapshot
const handleLockSnapshotAndProceed = async () => {
if (!createdGiveawayId) return;
setLockingSnapshot(true);
try {
const res = await fetch(`/api/giveaways/${createdGiveawayId}/snapshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filterRules: rules }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка создания неизменяемого слепка');
setLockedSnapshot(data.snapshot);
setStep(4);
} catch (err: any) {
alert(err.message);
} finally {
setLockingSnapshot(false);
}
};
// Step 4 handler: Execute Draw
const handleExecuteDraw = async () => {
if (!createdGiveawayId) return;
@ -345,7 +367,7 @@ export default function NewGiveawayWizardPage() {
<div>
<h2 className="text-xl font-bold text-white mb-1">Шаг 2: Условия участия</h2>
<p className="text-xs sm:text-sm text-slate-400">
Отметьте действия, которые участники должны выполнить для участия в розыгрыше
Отметьте условия, которые будут проверены у участников
</p>
</div>
@ -383,31 +405,12 @@ export default function NewGiveawayWizardPage() {
Оставил комментарий
</div>
<p className="text-xs text-slate-400 mt-0.5">
Требовать наличие как минимум 1 комментария
Требовать наличие комментария под постом
</p>
</div>
</label>
{/* Condition: Repost */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
checked={rules.requireRepost}
onChange={(e) => setRules({ ...rules, requireRepost: e.target.checked })}
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Repeat2 className="w-4 h-4 text-emerald-400" />
Сделал репост
</div>
<p className="text-xs text-slate-400 mt-0.5">
Проверять репост записи на открытую стену
</p>
</div>
</label>
{/* Condition: Subscription */}
{/* Condition: Subscription (Active & Supported) */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
@ -419,28 +422,12 @@ export default function NewGiveawayWizardPage() {
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Users className="w-4 h-4 text-indigo-400" />
Подписка на сообщество
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 rounded">
VK API
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">
Проверять членство в группе организатора
</p>
</div>
</label>
{/* Filter: Exclude Admins */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
checked={rules.excludeAdmins}
onChange={(e) => setRules({ ...rules, excludeAdmins: e.target.checked })}
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Shield className="w-4 h-4 text-amber-400" />
Исключить администраторов
</div>
<p className="text-xs text-slate-400 mt-0.5">
Не допускать к победе руководителей и контакты сообщества
Реальная пакетная проверка членства через groups.isMember
</p>
</div>
</label>
@ -463,6 +450,50 @@ export default function NewGiveawayWizardPage() {
</p>
</div>
</label>
{/* Condition: Repost (Explicitly Marked Unsupported by Capability) */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/40 border border-slate-800/50 opacity-60 cursor-not-allowed">
<input
type="checkbox"
disabled
checked={false}
className="mt-1 w-4 h-4 rounded text-slate-600 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-slate-400 flex items-center gap-2">
<Repeat2 className="w-4 h-4 text-slate-500" />
Сделал репост
<span className="text-[10px] font-medium px-1.5 py-0.5 bg-amber-500/20 text-amber-300 rounded">
Ограничение VK API
</span>
</div>
<p className="text-[11px] text-slate-400 mt-0.5">
Не поддерживается VK API для закрытых профилей сторонними приложениями
</p>
</div>
</div>
{/* Filter: Exclude Admins */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/40 border border-slate-800/50 opacity-60 cursor-not-allowed">
<input
type="checkbox"
disabled
checked={false}
className="mt-1 w-4 h-4 rounded text-slate-600 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-slate-400 flex items-center gap-2">
<Shield className="w-4 h-4 text-slate-500" />
Исключить администраторов
<span className="text-[10px] font-medium px-1.5 py-0.5 bg-amber-500/20 text-amber-300 rounded">
Этап 2 (OAuth)
</span>
</div>
<p className="text-[11px] text-slate-400 mt-0.5">
Требует авторизации организатора через VK ID для доступа к списку контактов
</p>
</div>
</div>
</div>
{/* Blacklist IDs */}
@ -494,24 +525,24 @@ export default function NewGiveawayWizardPage() {
{loadingParticipants ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Загрузка участников...
Загрузка и проверка...
</>
) : (
'Загрузить и отфильтровать участников →'
'Загрузить и проверить условия →'
)}
</button>
</div>
</div>
)}
{/* ================= STEP 3: Participants Table ================= */}
{/* ================= STEP 3: Participants Table & Lock Snapshot ================= */}
{step === 3 && (
<div className="bg-slate-900/70 border border-slate-800 rounded-2xl p-6 sm:p-8 space-y-6 shadow-xl">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-white mb-1">Шаг 3: Проверка участников</h2>
<p className="text-xs sm:text-sm text-slate-400">
Список загруженных пользователей и проверка выполнения условий
Список пользователей после применения фильтров и проверки подписок
</p>
</div>
@ -559,7 +590,6 @@ export default function NewGiveawayWizardPage() {
<th className="py-3 px-4">VK ID</th>
<th className="py-3 px-4 text-center">Лайк</th>
<th className="py-3 px-4 text-center">Коммент</th>
<th className="py-3 px-4 text-center">Репост</th>
<th className="py-3 px-4 text-center">Подписка</th>
<th className="py-3 px-4 text-right">Статус</th>
</tr>
@ -596,13 +626,6 @@ export default function NewGiveawayWizardPage() {
<span className="text-slate-400"></span>
)}
</td>
<td className="py-3 px-4 text-center">
{p.reposted ? (
<Check className="w-4 h-4 text-emerald-400 mx-auto" />
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="py-3 px-4 text-center">
{p.subscribed ? (
<Check className="w-4 h-4 text-indigo-400 mx-auto" />
@ -640,11 +663,21 @@ export default function NewGiveawayWizardPage() {
Назад к условиям
</button>
<button
onClick={() => setStep(4)}
disabled={eligibleParticipants.length === 0}
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-all shadow-md shadow-blue-600/30"
onClick={handleLockSnapshotAndProceed}
disabled={lockingSnapshot || eligibleParticipants.length === 0}
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-all shadow-md shadow-blue-600/30 flex items-center gap-2"
>
Перейти к розыгрышу ({eligibleParticipants.length} допущено)
{lockingSnapshot ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Фиксация слепка...
</>
) : (
<>
<Lock className="w-4 h-4" />
Зафиксировать слепок и перейти к розыгрышу ({eligibleParticipants.length})
</>
)}
</button>
</div>
</div>
@ -656,10 +689,23 @@ export default function NewGiveawayWizardPage() {
<div>
<h2 className="text-xl font-bold text-white mb-1">Шаг 4: Настройки жеребьевки</h2>
<p className="text-xs sm:text-sm text-slate-400">
Укажите количество победителей и параметры seed для криптографического выбора
Слепок участников зафиксирован (Статус: <span className="text-emerald-400 font-mono">SNAPSHOT_LOCKED</span>). Алгоритм: <span className="text-blue-400 font-mono">HMAC_SHA256_FY_V1</span>
</p>
</div>
{lockedSnapshot && (
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-slate-400">Snapshot ID:</span>
<span className="font-mono text-slate-300">{lockedSnapshot.id}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Хеш слепка участников (SHA-256):</span>
<span className="font-mono text-emerald-400 truncate max-w-xs">{lockedSnapshot.participantsSnapshotHash}</span>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Winners Count */}
<div className="space-y-2 p-4 bg-slate-950 border border-slate-800 rounded-xl">
@ -701,17 +747,17 @@ export default function NewGiveawayWizardPage() {
<Shuffle className="w-3.5 h-3.5 text-blue-400" />
Seed розыгрыша (опционально)
</label>
<span className="text-[10px] text-slate-400">Для предварительной публикации</span>
<span className="text-[10px] text-slate-400">CSPRNG / crypto.randomBytes</span>
</div>
<input
type="text"
placeholder="Оставьте пустым для автогенерации крипто-seed"
placeholder="Оставьте пустым для генерации крипто-стойкого CSPRNG seed"
value={seed}
onChange={(e) => setSeed(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white font-mono text-xs focus:outline-none focus:border-blue-500"
/>
<p className="text-[11px] text-slate-400">
Если seed не указан, система сгенерирует случайный крипто-ключ в момент запуска
Если seed не задан вручную, система сгенерирует 128-битный криптографический ключ Node.js CSPRNG
</p>
</div>
@ -720,7 +766,7 @@ export default function NewGiveawayWizardPage() {
onClick={() => setStep(3)}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition-colors"
>
Назад к участникам
Назад к списку
</button>
<button
onClick={handleExecuteDraw}
@ -756,7 +802,7 @@ export default function NewGiveawayWizardPage() {
🎉 Поздравляем победителей!
</h2>
<p className="text-xs sm:text-sm text-slate-300 max-w-lg mx-auto mb-6">
Выборка произведена детерминированным алгоритмом HMAC-SHA256 среди {drawResult.totalEligibleCount} допущенных участников.
Выборка произведена алгоритмом {drawResult.algorithmVersion} среди {drawResult.totalEligibleCount} допущенных участников.
</p>
{/* Main Winners Cards */}
@ -803,7 +849,7 @@ export default function NewGiveawayWizardPage() {
))}
</div>
{/* Reserve Winners (if any) */}
{/* Reserve Winners */}
{drawResult.reserveWinners.length > 0 && (
<div className="mt-6 pt-6 border-t border-slate-800 max-w-2xl mx-auto text-left">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">
@ -837,14 +883,7 @@ export default function NewGiveawayWizardPage() {
</div>
<button
onClick={() => {
const proofText = `Розыгрыш Randomayzer
Giveaway ID: ${drawResult.giveawayId}
Seed: ${drawResult.seedUsed}
Snapshot Hash (SHA-256): ${drawResult.participantsSnapshotHash}
Verification Signature: ${drawResult.verificationSignature}
Алгоритм: ${drawResult.algorithm}
Дата: ${drawResult.drawnAt}
Победители: ${drawResult.winners.map(w => `${w.participant.firstName} ${w.participant.lastName} (id${w.participant.platformUserId})`).join(', ')}`;
const proofText = JSON.stringify(drawResult, null, 2);
navigator.clipboard.writeText(proofText);
setCopiedProof(true);
setTimeout(() => setCopiedProof(false), 2000);
@ -859,7 +898,7 @@ Verification Signature: ${drawResult.verificationSignature}
) : (
<>
<Copy className="w-3.5 h-3.5" />
Скопировать протокол
Скопировать JSON аудита
</>
)}
</button>
@ -871,14 +910,24 @@ Verification Signature: ${drawResult.verificationSignature}
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1">
<span className="text-slate-400 font-medium">Алгоритм:</span>
<p className="font-mono text-amber-400 break-all">{drawResult.algorithmVersion}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1">
<span className="text-slate-400 font-medium">Хеш участников (SHA-256):</span>
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1">
<span className="text-slate-400 font-medium">Хеш условий (conditionsHash):</span>
<p className="font-mono text-purple-400 break-all">{drawResult.conditionsHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1 sm:col-span-2">
<span className="text-slate-400 font-medium">Цифровая подпись верификации:</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.verificationSignature}</p>
<span className="text-slate-400 font-medium">Канонический auditHash:</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p>
</div>
</div>

View file

@ -12,7 +12,6 @@ import {
ShieldCheck,
ArrowRight,
RefreshCw,
ExternalLink
} from 'lucide-react';
import { StoredGiveaway } from '@/lib/giveaway-store';
@ -39,7 +38,7 @@ export default function DashboardPage() {
fetchGiveaways();
}, []);
const completedCount = giveaways.filter(g => g.status === 'COMPLETED').length;
const completedCount = giveaways.filter(g => g.status === 'DRAWN' || g.status === 'PUBLISHED').length;
const totalEligible = giveaways.reduce((acc, g) => acc + (g.drawResult?.totalEligibleCount || 0), 0);
return (
@ -55,8 +54,8 @@ export default function DashboardPage() {
Честный рандомайзер с доказуемым результатом
</h1>
<p className="text-slate-300 text-sm sm:text-base mb-6 leading-relaxed">
Выбирайте победителей по лайкам, комментариям и репостам.
Каждый розыгрыш фиксируется криптографическим хешем и seed для 100% прозрачности.
Выбирайте победителей по лайкам и комментариям.
Каждый розыгрыш фиксируется неизменяемым слепком (Snapshot) и криптографическим auditHash (HMAC-SHA256).
</p>
<div className="flex flex-wrap items-center gap-4">
<Link
@ -127,7 +126,7 @@ export default function DashboardPage() {
<Gift className="w-10 h-10 text-slate-400 mx-auto mb-3" />
<p className="text-sm text-slate-300 font-medium mb-1">Пока нет созданных розыгрышей</p>
<p className="text-xs text-slate-400 mb-4 max-w-sm mx-auto">
Вставьте ссылку на пост ВКонтакте, чтобы загрузить участников и определить победителя
Вставьте ссылку на пост ВКонтакте, чтобы загрузить участников и зафиксировать результат
</p>
<Link
href="/giveaways/new"
@ -166,15 +165,20 @@ export default function DashboardPage() {
</div>
<div className="flex items-center gap-3 shrink-0 self-end sm:self-center">
{gw.status === 'COMPLETED' ? (
{gw.status === 'DRAWN' || gw.status === 'PUBLISHED' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="w-3 h-3" />
Завершен
{gw.status}
</span>
) : gw.status === 'SNAPSHOT_LOCKED' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20">
<Clock className="w-3 h-3" />
Слепок зафиксирован
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
<Clock className="w-3 h-3" />
Готов к проведению
{gw.status}
</span>
)}
@ -202,8 +206,8 @@ export default function DashboardPage() {
Как гарантируется честность результатов?
</h3>
<p className="text-xs text-slate-400 leading-relaxed">
Перед жеребьевкой список участников сортируется и хешируется по стандарту SHA-256.
Победитель определяется алгоритмом HMAC-SHA256 на основе фиксированного seed. Любой зритель может воспроизвести результат и проверить неизменность выборки.
Перед жеребьевкой список участников фиксируется в неизменяемый слепок и хешируется по стандарту SHA-256.
Победитель определяется алгоритмом HMAC_SHA256_FY_V1 с rejection sampling. Любой зритель может воспроизвести результат и проверить неизменность выборки.
</p>
</div>
</div>

View file

@ -0,0 +1,61 @@
import { GiveawayStatusType } from '../types/giveaway';
export class InvalidStateTransitionError extends Error {
constructor(from: GiveawayStatusType, to: GiveawayStatusType, reason?: string) {
super(`Invalid state transition from "${from}" to "${to}"${reason ? `: ${reason}` : ''}`);
this.name = 'InvalidStateTransitionError';
}
}
const ALLOWED_TRANSITIONS: Record<GiveawayStatusType, GiveawayStatusType[]> = {
DRAFT: ['FETCHING', 'CANCELLED'],
FETCHING: ['READY', 'CANCELLED'],
READY: ['SNAPSHOT_LOCKED', 'FETCHING', 'CANCELLED'],
SNAPSHOT_LOCKED: ['DRAWN', 'READY', 'CANCELLED'],
DRAWN: ['PUBLISHED', 'CANCELLED'],
PUBLISHED: [],
CANCELLED: [],
};
export class GiveawayFSM {
/**
* Check if a state transition is allowed
*/
static canTransition(from: GiveawayStatusType, to: GiveawayStatusType): boolean {
const allowed = ALLOWED_TRANSITIONS[from];
return allowed ? allowed.includes(to) : false;
}
/**
* Validate and assert transition, throwing InvalidStateTransitionError if illegal
*/
static validateTransition(from: GiveawayStatusType, to: GiveawayStatusType, reason?: string): void {
if (!this.canTransition(from, to)) {
throw new InvalidStateTransitionError(from, to, reason);
}
}
/**
* Guard: Verify that giveaway is in SNAPSHOT_LOCKED status before drawing
*/
static assertCanDraw(status: GiveawayStatusType): void {
if (status === 'DRAWN') {
throw new Error('Giveaway is already DRAWN. Duplicate draw is strictly forbidden.');
}
if (status !== 'SNAPSHOT_LOCKED') {
throw new Error(`Cannot draw winners: status must be "SNAPSHOT_LOCKED", but got "${status}". Lock participant snapshot first.`);
}
}
/**
* Guard: Verify that participants/rules can be modified
*/
static assertCanModifyParticipants(status: GiveawayStatusType): void {
if (status === 'SNAPSHOT_LOCKED') {
throw new Error('Cannot modify participants or rules while snapshot is locked. Unlock snapshot first.');
}
if (status === 'DRAWN' || status === 'PUBLISHED') {
throw new Error(`Cannot modify participants in final status "${status}".`);
}
}
}

View file

@ -0,0 +1,42 @@
import { FilterRules } from '../types/giveaway';
import { RawParticipant } from '../types/participant';
import { SocialMediaProvider } from '../../providers/types';
import { applyFilterRules, FilterResult } from '../filtering/filter-engine';
export interface EnrichmentPipelineParams {
rawParticipants: RawParticipant[];
rules: FilterRules;
provider: SocialMediaProvider;
ownerId: string;
}
/**
* Executes full participant enrichment and filtering pipeline:
* 1. Takes raw participants
* 2. Checks community subscription via provider if requireSubscription is true
* 3. Enriches participants with subscription status
* 4. Applies FilterEngine to determine final eligibility
*/
export async function executeParticipantPipeline(
params: EnrichmentPipelineParams
): Promise<FilterResult> {
const { rawParticipants, rules, provider, ownerId } = params;
let enrichedParticipants = rawParticipants.map(p => ({ ...p }));
// Subscription Enrichment Step
if (rules.requireSubscription) {
const userIds = Array.from(new Set(enrichedParticipants.map(p => p.platformUserId)));
const targetGroupId = rules.targetGroupId || (ownerId.startsWith('-') ? ownerId : undefined);
if (targetGroupId && userIds.length > 0 && provider.capabilities.subscriptions) {
const subMap = await provider.checkSubscription(userIds, targetGroupId);
for (const p of enrichedParticipants) {
p.subscribed = Boolean(subMap.get(p.platformUserId));
}
}
}
// Filter Engine Step
return applyFilterRules(enrichedParticipants, rules);
}

View file

@ -0,0 +1,90 @@
import { createHash } from 'crypto';
import { FilterRules } from '../types/giveaway';
import { FilteredParticipant } from '../types/participant';
/**
* Deterministic JSON stringifier that sorts object keys recursively.
*/
export function canonicalStringify(value: any): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return '[' + value.map(canonicalStringify).join(',') + ']';
}
const keys = Object.keys(value).sort();
const pairs = keys.map(k => `${JSON.stringify(k)}:${canonicalStringify(value[k])}`);
return '{' + pairs.join(',') + '}';
}
/**
* Computes SHA-256 hash from any string
*/
export function sha256(content: string): string {
return createHash('sha256').update(content, 'utf8').digest('hex');
}
/**
* Computes deterministic conditionsHash for given filter rules
*/
export function computeConditionsHash(rules: FilterRules): string {
const canonicalRules = {
excludeAdmins: Boolean(rules.excludeAdmins),
excludeBlacklistedIds: [...(rules.excludeBlacklistedIds || [])].map(s => s.trim().toLowerCase()).sort(),
excludeDuplicateComments: Boolean(rules.excludeDuplicateComments),
minEligibleParticipants: rules.minEligibleParticipants ?? 1,
requireComment: Boolean(rules.requireComment),
requireLike: Boolean(rules.requireLike),
requireRepost: Boolean(rules.requireRepost),
requireSubscription: Boolean(rules.requireSubscription),
targetGroupId: rules.targetGroupId || null,
};
return sha256(canonicalStringify(canonicalRules));
}
/**
* Computes deterministic snapshot hash for eligible participants.
* Canonical sort by platformUserId ensures invariance against retrieval order.
*/
export function computeParticipantsSnapshotHash(participants: FilteredParticipant[]): string {
const sorted = [...participants].sort((a, b) =>
a.platformUserId.localeCompare(b.platformUserId)
);
const canonicalItems = sorted.map(p => ({
actions: {
commented: Boolean(p.commented),
commentsCount: Number(p.commentsCount || 0),
liked: Boolean(p.liked),
reposted: Boolean(p.reposted),
subscribed: Boolean(p.subscribed),
},
id: String(p.platformUserId),
name: `${p.firstName} ${p.lastName}`.trim(),
username: p.username || '',
}));
return sha256(canonicalStringify(canonicalItems));
}
/**
* Computes canonical auditHash for the audit record
*/
export function computeAuditHash(data: {
algorithmVersion: string;
giveawayId: string;
snapshotId: string;
seed: string;
participantsSnapshotHash: string;
conditionsHash: string;
winnerIds: string[];
reserveWinnerIds: string[];
eligibleCount: number;
drawId: string;
drawnAt: string;
}): string {
return sha256(canonicalStringify(data));
}

View file

@ -1,75 +1,69 @@
import { createHmac, createHash } from 'crypto';
import { createHash, randomBytes } from 'crypto';
import { FilteredParticipant, Winner } from '../types/participant';
import { DrawExecutionParams, DrawExecutionResult } from '../types/audit';
import { computeParticipantsSnapshotHash } from './hasher';
import { DrawExecutionParams, DrawExecutionResult, CURRENT_RANDOMIZER_ALGORITHM, ParticipantSnapshotData } from '../types/audit';
import { DeterministicHmacStream } from './unbiased-sampler';
import { computeAuditHash } from './canonical';
const ALGORITHM_NAME = 'HMAC-SHA256-SEEDED-SELECTION-V1';
export const ALGORITHM_VERSION_V1 = CURRENT_RANDOMIZER_ALGORITHM; // 'HMAC_SHA256_FY_V1'
/**
* Deterministic pseudo-random number generator using HMAC-SHA256.
* Given a seed, snapshot hash, and step/index, generates a deterministic 32-bit unsigned integer.
* Generates an individual audit proof hash for a winner position
*/
export function getDeterministicUint32(seed: string, snapshotHash: string, step: number): number {
const hmac = createHmac('sha256', seed);
hmac.update(`${snapshotHash}:step:${step}`);
const hashBuffer = hmac.digest();
// Read first 4 bytes as unsigned 32-bit big endian integer
return hashBuffer.readUInt32BE(0);
}
/**
* Generates an audit proof hash for a specific winner step
*/
export function generateWinnerProofHash(seed: string, snapshotHash: string, position: number, participantId: string): string {
export function generateWinnerProofHash(
seed: string,
snapshotHash: string,
position: number,
participantId: string
): string {
return createHash('sha256')
.update(`${seed}:${snapshotHash}:pos:${position}:id:${participantId}`)
.digest('hex');
}
/**
* Executes a deterministic draw on a set of eligible participants.
* Guarantees that the same eligible participants + same seed ALWAYS produce the exact same winners.
* Executes deterministic Fisher-Yates selection V1 (HMAC_SHA256_FY_V1)
* with unbiased rejection sampling.
*/
export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult {
export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExecutionResult {
const {
giveawayId,
eligibleParticipants,
snapshot,
totalLoadedCount,
winnersCount,
reserveWinnersCount,
seed,
filterRules,
} = params;
if (eligibleParticipants.length === 0) {
throw new Error('Cannot conduct draw with 0 eligible participants');
const eligible = snapshot.eligibleParticipants;
if (!eligible || eligible.length === 0) {
throw new Error('Cannot conduct draw with 0 eligible participants in snapshot');
}
// 1. Canonical sort to guarantee stability
const sortedParticipants = [...eligibleParticipants].sort((a, b) =>
// 1. Canonical sort to ensure exact invariant input order
const pool = [...eligible].sort((a, b) =>
a.platformUserId.localeCompare(b.platformUserId)
);
// 2. Compute canonical snapshot hash
const snapshotHash = computeParticipantsSnapshotHash(sortedParticipants);
const snapshotHash = snapshot.participantsSnapshotHash;
const conditionsHash = snapshot.conditionsHash;
// 3. Clone pool for sampling without replacement
const pool = [...sortedParticipants];
const winners: Winner[] = [];
const reserveWinners: Winner[] = [];
// 2. Initialize unbiased HMAC stream keyed by seed and snapshotHash
const stream = new DeterministicHmacStream(seed, snapshotHash);
const totalNeeded = Math.min(winnersCount + reserveWinnersCount, pool.length);
const actualWinnersCount = Math.min(winnersCount, totalNeeded);
const actualReserveCount = Math.max(0, totalNeeded - actualWinnersCount);
let step = 0;
const winners: Winner[] = [];
const reserveWinners: Winner[] = [];
const winnerIds: string[] = [];
const reserveWinnerIds: string[] = [];
// 4. Select Main Winners
// 3. Select Main Winners (Fisher-Yates removal without replacement)
for (let i = 0; i < actualWinnersCount; i++) {
const randUint = getDeterministicUint32(seed, snapshotHash, step);
const selectedIndex = randUint % pool.length;
const selectedIndex = stream.sampleUnbiasedIndex(pool.length);
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId);
winners.push({
@ -79,71 +73,88 @@ export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecu
selectionIndex: selectedIndex,
proofHash,
});
step++;
winnerIds.push(selectedParticipant.platformUserId);
}
// 5. Select Reserve Winners
// 4. Select Reserve Winners
for (let i = 0; i < actualReserveCount; i++) {
const randUint = getDeterministicUint32(seed, snapshotHash, step);
const selectedIndex = randUint % pool.length;
const pos = winners.length + i + 1;
const selectedIndex = stream.sampleUnbiasedIndex(pool.length);
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
const proofHash = generateWinnerProofHash(seed, snapshotHash, winners.length + i + 1, selectedParticipant.platformUserId);
const proofHash = generateWinnerProofHash(seed, snapshotHash, pos, selectedParticipant.platformUserId);
reserveWinners.push({
position: winners.length + i + 1,
position: pos,
isReserve: true,
participant: selectedParticipant,
selectionIndex: selectedIndex,
proofHash,
});
step++;
reserveWinnerIds.push(selectedParticipant.platformUserId);
}
const drawId = 'draw_' + randomBytes(8).toString('hex');
const drawnAt = new Date().toISOString();
// 6. Compute overall verification signature
const verificationSignature = createHash('sha256')
.update(JSON.stringify({
giveawayId,
snapshotHash,
seed,
algorithm: ALGORITHM_NAME,
winnerIds: winners.map(w => w.participant.platformUserId),
reserveIds: reserveWinners.map(w => w.participant.platformUserId),
}))
.digest('hex');
// 5. Compute canonical auditHash
const auditHash = computeAuditHash({
algorithmVersion: ALGORITHM_VERSION_V1,
giveawayId,
snapshotId: snapshot.id,
seed,
participantsSnapshotHash: snapshotHash,
conditionsHash,
winnerIds,
reserveWinnerIds,
eligibleCount: eligible.length,
drawId,
drawnAt,
});
return {
drawId,
giveawayId,
snapshotId: snapshot.id,
winners,
reserveWinners,
totalEligibleCount: eligibleParticipants.length,
winnerIds,
reserveWinnerIds,
totalEligibleCount: eligible.length,
totalLoadedCount,
seedUsed: seed,
participantsSnapshotHash: snapshotHash,
algorithm: ALGORITHM_NAME,
conditionsHash,
algorithmVersion: ALGORITHM_VERSION_V1,
drawnAt,
verificationSignature,
auditHash,
};
}
/**
* Re-runs the draw algorithm with the given snapshot of participants and seed
* to verify if the produced outcome matches the claimed outcome.
* Universal entrypoint (routes to active algorithm version V1)
*/
export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult {
return executeDeterministicDrawV1(params);
}
/**
* Re-runs draw algorithm on a snapshot to verify identical outcome
*/
export function verifyDrawResult(
eligibleParticipants: FilteredParticipant[],
snapshot: ParticipantSnapshotData,
seed: string,
claimedWinnersCount: number,
claimedReserveCount: number
): { winners: Winner[]; reserveWinners: Winner[]; snapshotHash: string } {
const result = executeDeterministicDraw({
giveawayId: 'verification',
eligibleParticipants,
totalLoadedCount: eligibleParticipants.length,
claimedReserveCount: number,
algorithmVersion: string = ALGORITHM_VERSION_V1
): { winners: Winner[]; reserveWinners: Winner[]; winnerIds: string[]; reserveWinnerIds: string[]; auditHash: string } {
if (algorithmVersion !== ALGORITHM_VERSION_V1) {
throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`);
}
const result = executeDeterministicDrawV1({
giveawayId: snapshot.giveawayId,
snapshot,
totalLoadedCount: snapshot.participantCount,
winnersCount: claimedWinnersCount,
reserveWinnersCount: claimedReserveCount,
seed,
@ -153,6 +164,8 @@ export function verifyDrawResult(
return {
winners: result.winners,
reserveWinners: result.reserveWinners,
snapshotHash: result.participantsSnapshotHash,
winnerIds: result.winnerIds,
reserveWinnerIds: result.reserveWinnerIds,
auditHash: result.auditHash,
};
}

View file

@ -1,39 +1,13 @@
import { createHash } from 'crypto';
import { randomBytes } from 'crypto';
import { FilteredParticipant } from '../types/participant';
import { computeParticipantsSnapshotHash, computeConditionsHash, computeAuditHash } from './canonical';
export { computeParticipantsSnapshotHash, computeConditionsHash, computeAuditHash };
/**
* Computes a deterministic SHA-256 snapshot hash for a list of participants.
* Participants are sorted canonically by platformUserId to guarantee identical hash
* regardless of initial retrieval order.
* Generates a cryptographically secure random seed (128-bit / 32 hex chars) using CSPRNG.
* Math.random() is strictly forbidden in security-sensitive giveaway workflows.
*/
export function computeParticipantsSnapshotHash(participants: FilteredParticipant[]): string {
// Canonical sort by platformUserId
const sorted = [...participants].sort((a, b) =>
a.platformUserId.localeCompare(b.platformUserId)
);
const canonicalRepresentation = sorted.map(p => ({
id: p.platformUserId,
name: `${p.firstName} ${p.lastName}`.trim(),
username: p.username || '',
actions: {
liked: p.liked,
commented: p.commented,
reposted: p.reposted,
subscribed: p.subscribed,
}
}));
const jsonString = JSON.stringify(canonicalRepresentation);
return createHash('sha256').update(jsonString, 'utf8').digest('hex');
}
/**
* Generates a random crypto seed if not provided by user
*/
export function generateRandomSeed(): string {
return createHash('sha256')
.update(`${Date.now()}-${Math.random()}-${process.pid}`)
.digest('hex')
.slice(0, 16);
export function generateCryptoSecureSeed(): string {
return randomBytes(16).toString('hex');
}

View file

@ -0,0 +1,55 @@
import { createHmac } from 'crypto';
const UINT32_MAX = 0x100000000; // 2^32 = 4294967296
/**
* Deterministic HMAC-SHA256 CSPRNG Stream with Unbiased Rejection Sampling.
*/
export class DeterministicHmacStream {
private seed: string;
private context: string;
private counter: number = 0;
constructor(seed: string, context: string) {
this.seed = seed;
this.context = context;
}
/**
* Generates the next raw 32-bit unsigned integer from HMAC-SHA256 stream
*/
nextUint32(): number {
const hmac = createHmac('sha256', this.seed);
hmac.update(`${this.context}:ctr:${this.counter++}`);
const buffer = hmac.digest();
return buffer.readUInt32BE(0);
}
/**
* Generates an unbiased integer in range [0, range - 1] using Rejection Sampling.
* Eliminates modulo bias completely.
*/
sampleUnbiasedIndex(range: number): number {
if (range <= 0) {
throw new Error(`Invalid sampling range: ${range}`);
}
if (range === 1) {
return 0;
}
// Largest multiple of range <= 2^32
const maxValid = Math.floor(UINT32_MAX / range) * range;
while (true) {
const raw = this.nextUint32();
if (raw < maxValid) {
return raw % range;
}
// If raw >= maxValid, reject and retry to eliminate bias
}
}
getStreamCounter(): number {
return this.counter;
}
}

View file

@ -1,9 +1,22 @@
import { FilterRules } from './giveaway';
import { FilteredParticipant, Winner } from './participant';
export const CURRENT_RANDOMIZER_ALGORITHM = 'HMAC_SHA256_FY_V1';
export interface ParticipantSnapshotData {
id: string;
giveawayId: string;
version: number;
createdAt: string;
eligibleParticipants: FilteredParticipant[];
participantCount: number;
participantsSnapshotHash: string;
conditionsHash: string;
}
export interface DrawExecutionParams {
giveawayId: string;
eligibleParticipants: FilteredParticipant[];
snapshot: ParticipantSnapshotData;
totalLoadedCount: number;
winnersCount: number;
reserveWinnersCount: number;
@ -12,24 +25,36 @@ export interface DrawExecutionParams {
}
export interface DrawExecutionResult {
drawId: string;
giveawayId: string;
snapshotId: string;
winners: Winner[];
reserveWinners: Winner[];
winnerIds: string[];
reserveWinnerIds: string[];
totalEligibleCount: number;
totalLoadedCount: number;
seedUsed: string;
participantsSnapshotHash: string;
algorithm: string;
conditionsHash: string;
algorithmVersion: string;
drawnAt: string; // ISO String
verificationSignature: string;
auditHash: string;
}
export interface AuditVerificationData {
participantsSnapshotHash: string;
export interface AuditRecordData {
id: string;
giveawayId: string;
snapshotId: string;
algorithmVersion: string;
seed: string;
algorithm: string;
filterRulesSnapshot: FilterRules;
winners: Winner[];
reserveWinners: Winner[];
participantsSnapshotHash: string;
conditionsHash: string;
auditHash: string;
winnerIds: string[];
reserveWinnerIds: string[];
eligibleCount: number;
drawId: string;
drawnAt: string;
verifiedAt: string;
}

View file

@ -1,6 +1,13 @@
export type PlatformType = 'VK' | 'TELEGRAM' | 'YOUTUBE';
export type GiveawayStatusType = 'DRAFT' | 'FETCHING' | 'READY' | 'COMPLETED' | 'CANCELLED';
export type GiveawayStatusType =
| 'DRAFT'
| 'FETCHING'
| 'READY'
| 'SNAPSHOT_LOCKED'
| 'DRAWN'
| 'PUBLISHED'
| 'CANCELLED';
export type ParticipantSourceType = 'LIKES' | 'COMMENTS' | 'REPOSTS' | 'COMBINED';
@ -21,7 +28,7 @@ export const DEFAULT_FILTER_RULES: FilterRules = {
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: true,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
minEligibleParticipants: 1,

View file

@ -1,102 +1,80 @@
import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../core/types/giveaway';
import { FilteredParticipant, RawParticipant, Winner } from '../core/types/participant';
import { DrawExecutionResult } from '../core/types/audit';
import { IGiveawayRepository, GiveawayWithRelations, CreateGiveawayInput } from './repository/giveaway-repository';
import { PrismaGiveawayRepository } from './repository/prisma-repository';
import { MemoryGiveawayRepository } from './repository/memory-repository';
import { FilterRules, GiveawayStatusType } from '../core/types/giveaway';
import { FilteredParticipant } from '../core/types/participant';
import { DrawExecutionResult, ParticipantSnapshotData } from '../core/types/audit';
export interface StoredGiveaway {
id: string;
platform: PlatformType;
sourceUrl: string;
platformOwnerId: string;
platformPostId: string;
title: string;
description?: string;
postImageUrl?: string;
postLikesCount: number;
postCommentsCount: number;
postRepostsCount: number;
status: GiveawayStatusType;
filterRules: FilterRules;
winnersCount: number;
reserveWinnersCount: number;
seed?: string;
createdAt: string;
updatedAt: string;
drawnAt?: string;
participants: FilteredParticipant[];
drawResult?: DrawExecutionResult;
}
export type StoredGiveaway = GiveawayWithRelations;
// In-memory runtime cache/store for fast UI state & standalone dev mode
const memoryStore = new Map<string, StoredGiveaway>();
let activeRepository: IGiveawayRepository = new PrismaGiveawayRepository();
export class GiveawayStore {
static async create(data: {
sourceUrl: string;
post: PostMetadata;
filterRules: FilterRules;
winnersCount?: number;
reserveWinnersCount?: number;
seed?: string;
}): Promise<StoredGiveaway> {
const id = 'gw_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const now = new Date().toISOString();
/**
* Set custom repository (e.g. MemoryGiveawayRepository in tests)
*/
static setRepository(repo: IGiveawayRepository): void {
activeRepository = repo;
}
const giveaway: StoredGiveaway = {
id,
platform: data.post.platform,
sourceUrl: data.sourceUrl,
platformOwnerId: data.post.ownerId,
platformPostId: data.post.postId,
title: data.post.title,
description: data.post.text,
postImageUrl: data.post.imageUrl,
postLikesCount: data.post.likesCount,
postCommentsCount: data.post.commentsCount,
postRepostsCount: data.post.repostsCount,
status: 'READY',
filterRules: data.filterRules,
winnersCount: data.winnersCount || 1,
reserveWinnersCount: data.reserveWinnersCount || 0,
seed: data.seed,
createdAt: now,
updatedAt: now,
participants: [],
};
static getRepository(): IGiveawayRepository {
return activeRepository;
}
memoryStore.set(id, giveaway);
return giveaway;
static async create(input: CreateGiveawayInput): Promise<StoredGiveaway> {
try {
return await activeRepository.createGiveaway(input);
} catch (err) {
if (activeRepository instanceof PrismaGiveawayRepository) {
console.warn('Prisma DB error, falling back to memory repository:', (err as Error).message);
activeRepository = new MemoryGiveawayRepository();
return await activeRepository.createGiveaway(input);
}
throw err;
}
}
static async getById(id: string): Promise<StoredGiveaway | null> {
return memoryStore.get(id) || null;
try {
return await activeRepository.getGiveawayById(id);
} catch (err) {
if (activeRepository instanceof PrismaGiveawayRepository) {
activeRepository = new MemoryGiveawayRepository();
return await activeRepository.getGiveawayById(id);
}
throw err;
}
}
static async listAll(): Promise<StoredGiveaway[]> {
return Array.from(memoryStore.values()).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
try {
return await activeRepository.listGiveaways();
} catch (err) {
if (activeRepository instanceof PrismaGiveawayRepository) {
activeRepository = new MemoryGiveawayRepository();
return await activeRepository.listGiveaways();
}
throw err;
}
}
static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> {
const gw = memoryStore.get(id);
if (!gw) throw new Error('Giveaway not found');
gw.participants = participants;
gw.updatedAt = new Date().toISOString();
memoryStore.set(id, gw);
return gw;
return await activeRepository.saveParticipants(id, participants);
}
static async saveDrawResult(id: string, result: DrawExecutionResult): Promise<StoredGiveaway> {
const gw = memoryStore.get(id);
if (!gw) throw new Error('Giveaway not found');
static async createAndLockSnapshot(
id: string,
eligibleParticipants: FilteredParticipant[],
rules: FilterRules
): Promise<ParticipantSnapshotData> {
return await activeRepository.createAndLockSnapshot(id, eligibleParticipants, rules);
}
gw.drawResult = result;
gw.status = 'COMPLETED';
gw.drawnAt = result.drawnAt;
gw.seed = result.seedUsed;
gw.updatedAt = new Date().toISOString();
memoryStore.set(id, gw);
return gw;
static async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
return await activeRepository.getLatestSnapshot(giveawayId);
}
static async saveDrawResult(id: string, snapshotId: string, result: DrawExecutionResult): Promise<StoredGiveaway> {
return await activeRepository.saveDrawResultAndAudit(id, snapshotId, result);
}
}

View file

@ -0,0 +1,49 @@
import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../../core/types/giveaway';
import { FilteredParticipant, RawParticipant } from '../../core/types/participant';
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
export interface CreateGiveawayInput {
sourceUrl: string;
post: PostMetadata;
filterRules: FilterRules;
winnersCount?: number;
reserveWinnersCount?: number;
seed?: string;
}
export interface GiveawayWithRelations {
id: string;
platform: PlatformType;
sourceUrl: string;
platformOwnerId: string;
platformPostId: string;
title: string;
description: string | null;
postImageUrl: string | null;
postLikesCount: number;
postCommentsCount: number;
postRepostsCount: number;
status: GiveawayStatusType;
filterRules: FilterRules;
winnersCount: number;
reserveWinnersCount: number;
seed: string | null;
createdAt: string;
updatedAt: string;
drawnAt: string | null;
participants: FilteredParticipant[];
snapshots: ParticipantSnapshotData[];
latestSnapshot?: ParticipantSnapshotData | null;
drawResult?: DrawExecutionResult | null;
}
export interface IGiveawayRepository {
createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations>;
getGiveawayById(id: string): Promise<GiveawayWithRelations | null>;
listGiveaways(): Promise<GiveawayWithRelations[]>;
updateStatus(id: string, status: GiveawayStatusType): Promise<GiveawayWithRelations>;
saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations>;
createAndLockSnapshot(id: string, eligibleParticipants: FilteredParticipant[], rules: FilterRules): Promise<ParticipantSnapshotData>;
getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null>;
saveDrawResultAndAudit(id: string, snapshotId: string, result: DrawExecutionResult): Promise<GiveawayWithRelations>;
}

View file

@ -0,0 +1,163 @@
import {
IGiveawayRepository,
CreateGiveawayInput,
GiveawayWithRelations
} from './giveaway-repository';
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
import { FilteredParticipant } from '../../core/types/participant';
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
export class MemoryGiveawayRepository implements IGiveawayRepository {
private giveaways: Map<string, GiveawayWithRelations> = new Map();
private snapshots: Map<string, ParticipantSnapshotData[]> = new Map();
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
const id = 'gw_' + Math.random().toString(36).slice(2, 10);
const now = new Date().toISOString();
const gw: GiveawayWithRelations = {
id,
platform: input.post.platform,
sourceUrl: input.sourceUrl,
platformOwnerId: input.post.ownerId,
platformPostId: input.post.postId,
title: input.post.title,
description: input.post.text,
postImageUrl: input.post.imageUrl || null,
postLikesCount: input.post.likesCount,
postCommentsCount: input.post.commentsCount,
postRepostsCount: input.post.repostsCount,
status: 'READY',
filterRules: input.filterRules,
winnersCount: input.winnersCount || 1,
reserveWinnersCount: input.reserveWinnersCount || 0,
seed: input.seed || null,
createdAt: now,
updatedAt: now,
drawnAt: null,
participants: [],
snapshots: [],
latestSnapshot: null,
drawResult: null,
};
this.giveaways.set(id, gw);
this.snapshots.set(id, []);
return gw;
}
async getGiveawayById(id: string): Promise<GiveawayWithRelations | null> {
const gw = this.giveaways.get(id);
if (!gw) return null;
const snaps = this.snapshots.get(id) || [];
const latest = snaps.length > 0 ? snaps[snaps.length - 1] : null;
return {
...gw,
snapshots: [...snaps],
latestSnapshot: latest,
};
}
async listGiveaways(): Promise<GiveawayWithRelations[]> {
const all = Array.from(this.giveaways.values());
return all.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
const gw = await this.getGiveawayById(id);
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
GiveawayFSM.validateTransition(gw.status, newStatus);
gw.status = newStatus;
gw.updatedAt = new Date().toISOString();
this.giveaways.set(id, gw);
return gw;
}
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
const gw = await this.getGiveawayById(id);
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
GiveawayFSM.assertCanModifyParticipants(gw.status);
gw.participants = participants;
gw.status = 'READY';
gw.updatedAt = new Date().toISOString();
this.giveaways.set(id, gw);
return gw;
}
async createAndLockSnapshot(
id: string,
eligibleParticipants: FilteredParticipant[],
rules: FilterRules
): Promise<ParticipantSnapshotData> {
const gw = await this.getGiveawayById(id);
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
if (gw.status === 'DRAWN' || gw.status === 'PUBLISHED') {
throw new Error(`Cannot lock snapshot in final status "${gw.status}"`);
}
if (eligibleParticipants.length === 0) {
throw new Error('Cannot create snapshot with 0 eligible participants');
}
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
const conditionsHash = computeConditionsHash(rules);
const snaps = this.snapshots.get(id) || [];
const newVersion = snaps.length + 1;
const snapId = 'snap_' + Math.random().toString(36).slice(2, 10);
const snapshot: ParticipantSnapshotData = {
id: snapId,
giveawayId: id,
version: newVersion,
createdAt: new Date().toISOString(),
eligibleParticipants: [...eligibleParticipants],
participantCount: eligibleParticipants.length,
participantsSnapshotHash,
conditionsHash,
};
snaps.push(snapshot);
this.snapshots.set(id, snaps);
gw.status = 'SNAPSHOT_LOCKED';
gw.filterRules = rules;
gw.latestSnapshot = snapshot;
gw.updatedAt = new Date().toISOString();
this.giveaways.set(id, gw);
return snapshot;
}
async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
const snaps = this.snapshots.get(giveawayId) || [];
return snaps.length > 0 ? snaps[snaps.length - 1] : null;
}
async saveDrawResultAndAudit(
id: string,
snapshotId: string,
result: DrawExecutionResult
): Promise<GiveawayWithRelations> {
const gw = await this.getGiveawayById(id);
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
GiveawayFSM.assertCanDraw(gw.status);
gw.drawResult = result;
gw.status = 'DRAWN';
gw.drawnAt = result.drawnAt;
gw.seed = result.seedUsed;
gw.updatedAt = new Date().toISOString();
this.giveaways.set(id, gw);
return gw;
}
}

View file

@ -0,0 +1,352 @@
import { prisma } from '../prisma';
import {
IGiveawayRepository,
CreateGiveawayInput,
GiveawayWithRelations
} from './giveaway-repository';
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
import { FilteredParticipant } from '../../core/types/participant';
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
export class PrismaGiveawayRepository implements IGiveawayRepository {
private mapPrismaGiveaway(raw: any): GiveawayWithRelations {
const participants: FilteredParticipant[] = (raw.participants || []).map((p: any) => ({
platformUserId: p.platformUserId,
firstName: p.firstName,
lastName: p.lastName,
username: p.username || undefined,
avatarUrl: p.avatarUrl || undefined,
source: p.source,
liked: p.liked,
commented: p.commented,
commentsCount: p.commentsCount,
reposted: p.reposted,
subscribed: p.subscribed,
eligible: p.eligible,
exclusionReason: p.exclusionReason,
}));
const snapshots: ParticipantSnapshotData[] = (raw.snapshots || []).map((s: any) => ({
id: s.id,
giveawayId: s.giveawayId,
version: s.version,
createdAt: s.createdAt.toISOString(),
eligibleParticipants: s.eligibleParticipants as FilteredParticipant[],
participantCount: s.participantCount,
participantsSnapshotHash: s.participantsSnapshotHash,
conditionsHash: s.conditionsHash,
}));
const latestSnapshot = snapshots.length > 0
? snapshots.sort((a, b) => b.version - a.version)[0]
: null;
let drawResult: DrawExecutionResult | null = null;
if (raw.drawResult) {
drawResult = {
drawId: raw.drawResult.id,
giveawayId: raw.drawResult.giveawayId,
snapshotId: raw.drawResult.snapshotId,
winners: raw.drawResult.winners as any,
reserveWinners: raw.drawResult.reserveWinners as any,
winnerIds: raw.drawResult.winnerIds as any,
reserveWinnerIds: raw.drawResult.reserveWinnerIds as any,
totalEligibleCount: raw.drawResult.totalEligibleCount,
totalLoadedCount: raw.drawResult.totalLoadedCount,
seedUsed: raw.drawResult.seedUsed,
participantsSnapshotHash: latestSnapshot?.participantsSnapshotHash || '',
conditionsHash: latestSnapshot?.conditionsHash || '',
algorithmVersion: raw.drawResult.algorithmVersion,
drawnAt: raw.drawResult.drawnAt.toISOString(),
auditHash: raw.drawResult.auditHash,
};
}
return {
id: raw.id,
platform: raw.platform as PlatformType,
sourceUrl: raw.sourceUrl,
platformOwnerId: raw.platformOwnerId,
platformPostId: raw.platformPostId,
title: raw.title,
description: raw.description,
postImageUrl: raw.postImageUrl,
postLikesCount: raw.postLikesCount,
postCommentsCount: raw.postCommentsCount,
postRepostsCount: raw.postRepostsCount,
status: raw.status as GiveawayStatusType,
filterRules: raw.filterRules as FilterRules,
winnersCount: raw.winnersCount,
reserveWinnersCount: raw.reserveWinnersCount,
seed: raw.seed,
createdAt: raw.createdAt.toISOString(),
updatedAt: raw.updatedAt.toISOString(),
drawnAt: raw.drawnAt ? raw.drawnAt.toISOString() : null,
participants,
snapshots,
latestSnapshot,
drawResult,
};
}
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
const created = await prisma.giveaway.create({
data: {
platform: input.post.platform,
sourceUrl: input.sourceUrl,
platformOwnerId: input.post.ownerId,
platformPostId: input.post.postId,
title: input.post.title,
description: input.post.text,
postImageUrl: input.post.imageUrl,
postLikesCount: input.post.likesCount,
postCommentsCount: input.post.commentsCount,
postRepostsCount: input.post.repostsCount,
status: 'READY',
filterRules: input.filterRules as any,
winnersCount: input.winnersCount || 1,
reserveWinnersCount: input.reserveWinnersCount || 0,
seed: input.seed,
},
include: {
participants: true,
snapshots: true,
drawResult: true,
},
});
return this.mapPrismaGiveaway(created);
}
async getGiveawayById(id: string): Promise<GiveawayWithRelations | null> {
const raw = await prisma.giveaway.findUnique({
where: { id },
include: {
participants: true,
snapshots: {
orderBy: { version: 'desc' },
},
drawResult: true,
},
});
return raw ? this.mapPrismaGiveaway(raw) : null;
}
async listGiveaways(): Promise<GiveawayWithRelations[]> {
const list = await prisma.giveaway.findMany({
orderBy: { createdAt: 'desc' },
include: {
participants: true,
snapshots: {
orderBy: { version: 'desc' },
},
drawResult: true,
},
});
return list.map(item => this.mapPrismaGiveaway(item));
}
async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
const current = await this.getGiveawayById(id);
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
GiveawayFSM.validateTransition(current.status, newStatus);
const updated = await prisma.giveaway.update({
where: { id },
data: { status: newStatus as any },
include: {
participants: true,
snapshots: true,
drawResult: true,
},
});
return this.mapPrismaGiveaway(updated);
}
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
const current = await this.getGiveawayById(id);
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
GiveawayFSM.assertCanModifyParticipants(current.status);
await prisma.$transaction(async (tx) => {
// Clear previous live participants
await tx.participant.deleteMany({ where: { giveawayId: id } });
// Insert new participants
if (participants.length > 0) {
await tx.participant.createMany({
data: participants.map(p => ({
giveawayId: id,
platformUserId: p.platformUserId,
firstName: p.firstName,
lastName: p.lastName,
username: p.username,
avatarUrl: p.avatarUrl,
source: p.source as any,
liked: p.liked,
commented: p.commented,
commentsCount: p.commentsCount,
reposted: p.reposted,
subscribed: p.subscribed,
eligible: p.eligible,
exclusionReason: p.exclusionReason,
})),
});
}
await tx.giveaway.update({
where: { id },
data: { status: 'READY' },
});
});
const updated = await this.getGiveawayById(id);
return updated!;
}
async createAndLockSnapshot(
id: string,
eligibleParticipants: FilteredParticipant[],
rules: FilterRules
): Promise<ParticipantSnapshotData> {
const current = await this.getGiveawayById(id);
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
if (current.status === 'DRAWN' || current.status === 'PUBLISHED') {
throw new Error(`Cannot lock snapshot in final status "${current.status}"`);
}
if (eligibleParticipants.length === 0) {
throw new Error('Cannot create snapshot with 0 eligible participants');
}
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
const conditionsHash = computeConditionsHash(rules);
const latestVersion = current.snapshots.length > 0
? Math.max(...current.snapshots.map(s => s.version))
: 0;
const newVersion = latestVersion + 1;
const [snapshot] = await prisma.$transaction([
prisma.participantSnapshot.create({
data: {
giveawayId: id,
version: newVersion,
eligibleParticipants: eligibleParticipants as any,
participantCount: eligibleParticipants.length,
participantsSnapshotHash,
conditionsHash,
},
}),
prisma.giveaway.update({
where: { id },
data: {
status: 'SNAPSHOT_LOCKED',
filterRules: rules as any,
},
}),
]);
return {
id: snapshot.id,
giveawayId: snapshot.giveawayId,
version: snapshot.version,
createdAt: snapshot.createdAt.toISOString(),
eligibleParticipants: eligibleParticipants,
participantCount: snapshot.participantCount,
participantsSnapshotHash: snapshot.participantsSnapshotHash,
conditionsHash: snapshot.conditionsHash,
};
}
async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
const snap = await prisma.participantSnapshot.findFirst({
where: { giveawayId },
orderBy: { version: 'desc' },
});
if (!snap) return null;
return {
id: snap.id,
giveawayId: snap.giveawayId,
version: snap.version,
createdAt: snap.createdAt.toISOString(),
eligibleParticipants: snap.eligibleParticipants as any,
participantCount: snap.participantCount,
participantsSnapshotHash: snap.participantsSnapshotHash,
conditionsHash: snap.conditionsHash,
};
}
async saveDrawResultAndAudit(
id: string,
snapshotId: string,
result: DrawExecutionResult
): Promise<GiveawayWithRelations> {
const current = await this.getGiveawayById(id);
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
GiveawayFSM.assertCanDraw(current.status);
await prisma.$transaction(async (tx) => {
// 1. Create DrawResult
await tx.drawResult.create({
data: {
giveawayId: id,
snapshotId: snapshotId,
winners: result.winners as any,
reserveWinners: result.reserveWinners as any,
winnerIds: result.winnerIds as any,
reserveWinnerIds: result.reserveWinnerIds as any,
totalEligibleCount: result.totalEligibleCount,
totalLoadedCount: result.totalLoadedCount,
seedUsed: result.seedUsed,
algorithmVersion: result.algorithmVersion,
auditHash: result.auditHash,
drawnAt: new Date(result.drawnAt),
},
});
// 2. Create AuditRecord
await tx.auditRecord.create({
data: {
giveawayId: id,
snapshotId: snapshotId,
algorithmVersion: result.algorithmVersion,
seed: result.seedUsed,
participantsSnapshotHash: result.participantsSnapshotHash,
conditionsHash: result.conditionsHash,
auditHash: result.auditHash,
winnerIds: result.winnerIds as any,
reserveWinnerIds: result.reserveWinnerIds as any,
eligibleCount: result.totalEligibleCount,
drawId: result.drawId,
drawnAt: new Date(result.drawnAt),
verifiedAt: new Date(),
},
});
// 3. Update Giveaway status to DRAWN
await tx.giveaway.update({
where: { id },
data: {
status: 'DRAWN',
drawnAt: new Date(result.drawnAt),
seed: result.seedUsed,
},
});
});
const updated = await this.getGiveawayById(id);
return updated!;
}
}

View file

@ -1,6 +1,16 @@
import { PlatformType, PostMetadata } from '../core/types/giveaway';
import { RawParticipant } from '../core/types/participant';
export interface ProviderCapabilities {
likes: boolean;
comments: boolean;
reposts: boolean;
subscriptions: boolean;
adminDetection: boolean;
repostsNote?: string;
adminDetectionNote?: string;
}
export interface FetchParticipantsParams {
ownerId: string;
postId: string;
@ -9,13 +19,13 @@ export interface FetchParticipantsParams {
includeLikes?: boolean;
includeComments?: boolean;
includeReposts?: boolean;
checkSubscription?: boolean;
onProgress?: (loaded: number, total: number, message: string) => void;
}
export interface SocialMediaProvider {
readonly platform: PlatformType;
readonly capabilities: ProviderCapabilities;
/**
* Parse a raw URL from the user into ownerId and postId
*/

View file

@ -1,10 +1,19 @@
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
import { RawParticipant } from '../../core/types/participant';
import { FetchParticipantsParams, SocialMediaProvider } from '../types';
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
import { parseVkPostUrl } from './vk-parser';
export class VkMockProvider implements SocialMediaProvider {
readonly platform: PlatformType = 'VK';
readonly capabilities: ProviderCapabilities = {
likes: true,
comments: true,
reposts: false,
repostsNote: 'Не поддерживается VK API из-за ограничений приватности закрытых профилей',
subscriptions: true,
adminDetection: false,
adminDetectionNote: 'Требует расширенных прав администратора сообщества',
};
parsePostUrl(url: string): { ownerId: string; postId: string } | null {
return parseVkPostUrl(url);
@ -15,8 +24,7 @@ export class VkMockProvider implements SocialMediaProvider {
const ownerId = parsed ? parsed.ownerId : '-22446688';
const postId = parsed ? parsed.postId : '1054';
// Simulate short network latency
await new Promise(r => setTimeout(r, 400));
await new Promise(r => setTimeout(r, 200));
return {
platform: 'VK',
@ -36,15 +44,6 @@ export class VkMockProvider implements SocialMediaProvider {
}
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
// Simulate pagination / progress
if (params.onProgress) {
params.onProgress(50, 150, 'Загрузка лайков...');
await new Promise(r => setTimeout(r, 200));
params.onProgress(100, 150, 'Загрузка комментариев...');
await new Promise(r => setTimeout(r, 200));
params.onProgress(150, 150, 'Проверка подписок...');
}
const mockNames = [
{ first: 'Алексей', last: 'Смирнов', user: 'smirnov_alex' },
{ first: 'Екатерина', last: 'Иванова', user: 'katya_iva' },
@ -75,18 +74,16 @@ export class VkMockProvider implements SocialMediaProvider {
const participants: RawParticipant[] = [];
// Generate 35 mock participants with varied attributes
for (let i = 1; i <= 35; i++) {
const nameObj = mockNames[(i - 1) % mockNames.length];
const userId = `${1000000 + i * 137}`;
// Determine varied conditions for realistic testing
const liked = i !== 7 && i !== 19; // 7 and 19 didn't like
const commented = i % 2 === 0 || i % 3 === 0; // some commented
const commentsCount = commented ? (i % 5 === 0 ? 3 : 1) : 0; // some wrote duplicate comments
const reposted = i % 3 === 0; // some reposted
const subscribed = i !== 13 && i !== 27; // 13 and 27 not subscribed
const isAdmin = i === 1; // Participant 1 is admin
const liked = i !== 7 && i !== 19;
const commented = i % 2 === 0 || i % 3 === 0;
const commentsCount = commented ? (i % 5 === 0 ? 3 : 1) : 0;
const reposted = false; // explicitly false as per capabilities
const subscribed = false; // will be resolved via checkSubscription
const isAdmin = false;
participants.push({
platformUserId: userId,
@ -110,7 +107,9 @@ export class VkMockProvider implements SocialMediaProvider {
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
for (const id of userIds) {
result.set(id, id !== '1001781'); // mock
// Mock: users ending in 0 or 5 are not subscribed, others are subscribed
const num = parseInt(id, 10);
result.set(id, num % 5 !== 0);
}
return result;
}

View file

@ -1,6 +1,6 @@
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
import { RawParticipant } from '../../core/types/participant';
import { FetchParticipantsParams, SocialMediaProvider } from '../types';
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
import { parseVkPostUrl } from './vk-parser';
interface VkApiResponse<T> {
@ -13,6 +13,16 @@ interface VkApiResponse<T> {
export class VkProvider implements SocialMediaProvider {
readonly platform: PlatformType = 'VK';
readonly capabilities: ProviderCapabilities = {
likes: true,
comments: true,
reposts: false,
repostsNote: 'Сбор репостов ограничен политикой приватности VK для закрытых профилей',
subscriptions: true,
adminDetection: false,
adminDetectionNote: 'Требует расширенных прав администратора группы',
};
private serviceToken?: string;
private apiVersion = '5.199';
private baseUrl = 'https://api.vk.com/method';
@ -80,7 +90,6 @@ export class VkProvider implements SocialMediaProvider {
const post = response.items[0];
// Find author name / avatar
let authorName = `VK Wall ${ownerId}`;
let authorAvatarUrl = undefined;
@ -100,7 +109,6 @@ export class VkProvider implements SocialMediaProvider {
}
}
// Extract first image attachment if available
let imageUrl = undefined;
if (post.attachments && post.attachments.length > 0) {
const photoAttachment = post.attachments.find((a: any) => a.type === 'photo');
@ -171,7 +179,7 @@ export class VkProvider implements SocialMediaProvider {
if (params.onProgress) {
params.onProgress(participantsMap.size, totalLikes, 'Загрузка лайков...');
}
} while (offset < totalLikes && offset < 5000); // capped for phase 1 protection
} while (offset < totalLikes && offset < 5000);
}
// 2. Fetch Comments
@ -236,7 +244,6 @@ export class VkProvider implements SocialMediaProvider {
const cleanGroupId = groupId.replace(/^-/, '');
const resultMap = new Map<string, boolean>();
// Batch in chunks of 500 as supported by groups.isMember
const chunkSize = 500;
for (let i = 0; i < userIds.length; i += chunkSize) {
const chunk = userIds.slice(i, i + chunkSize);

47
tests/fsm.test.ts Normal file
View file

@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { GiveawayFSM, InvalidStateTransitionError } from '../src/core/fsm/giveaway-fsm';
describe('Giveaway Lifecycle State Machine (FSM)', () => {
it('should allow valid happy path state transitions', () => {
expect(GiveawayFSM.canTransition('DRAFT', 'FETCHING')).toBe(true);
expect(GiveawayFSM.canTransition('FETCHING', 'READY')).toBe(true);
expect(GiveawayFSM.canTransition('READY', 'SNAPSHOT_LOCKED')).toBe(true);
expect(GiveawayFSM.canTransition('SNAPSHOT_LOCKED', 'DRAWN')).toBe(true);
expect(GiveawayFSM.canTransition('DRAWN', 'PUBLISHED')).toBe(true);
});
it('should allow unlocking snapshot back to READY or re-fetching', () => {
expect(GiveawayFSM.canTransition('SNAPSHOT_LOCKED', 'READY')).toBe(true);
expect(GiveawayFSM.canTransition('READY', 'FETCHING')).toBe(true);
});
it('should forbid illegal transitions', () => {
expect(GiveawayFSM.canTransition('DRAFT', 'DRAWN')).toBe(false);
expect(GiveawayFSM.canTransition('READY', 'DRAWN')).toBe(false);
expect(GiveawayFSM.canTransition('DRAWN', 'DRAFT')).toBe(false);
expect(GiveawayFSM.canTransition('DRAWN', 'READY')).toBe(false);
expect(GiveawayFSM.canTransition('PUBLISHED', 'DRAFT')).toBe(false);
});
it('should throw InvalidStateTransitionError on illegal validateTransition', () => {
expect(() => {
GiveawayFSM.validateTransition('DRAFT', 'DRAWN');
}).toThrow(InvalidStateTransitionError);
});
it('should strictly forbid drawing when status is not SNAPSHOT_LOCKED', () => {
expect(() => GiveawayFSM.assertCanDraw('DRAFT')).toThrow(/must be "SNAPSHOT_LOCKED"/);
expect(() => GiveawayFSM.assertCanDraw('FETCHING')).toThrow(/must be "SNAPSHOT_LOCKED"/);
expect(() => GiveawayFSM.assertCanDraw('READY')).toThrow(/must be "SNAPSHOT_LOCKED"/);
});
it('should strictly forbid second draw when status is DRAWN', () => {
expect(() => GiveawayFSM.assertCanDraw('DRAWN')).toThrow(/Duplicate draw is strictly forbidden/);
});
it('should forbid modifying participants when snapshot is locked or drawn', () => {
expect(() => GiveawayFSM.assertCanModifyParticipants('SNAPSHOT_LOCKED')).toThrow(/while snapshot is locked/);
expect(() => GiveawayFSM.assertCanModifyParticipants('DRAWN')).toThrow(/final status "DRAWN"/);
expect(() => GiveawayFSM.assertCanModifyParticipants('PUBLISHED')).toThrow(/final status "PUBLISHED"/);
});
});

127
tests/persistence.test.ts Normal file
View file

@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { FilteredParticipant } from '../src/core/types/participant';
import { executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic';
describe('Repository Persistence & Lifecycle Scenario', () => {
const sampleParticipants: FilteredParticipant[] = [
{
platformUserId: '1001',
firstName: 'Дмитрий',
lastName: 'Попов',
source: 'LIKES',
liked: true,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
{
platformUserId: '1002',
firstName: 'Мария',
lastName: 'Иванова',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
{
platformUserId: '1003',
firstName: 'Олег',
lastName: 'Сидоров',
source: 'LIKES',
liked: true,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
];
it('should execute complete giveaway lifecycle with snapshot locking and persistent draw result', async () => {
const repo = new MemoryGiveawayRepository();
// 1. Create Giveaway
const gw = await repo.createGiveaway({
sourceUrl: 'https://vk.com/wall-22446688_1054',
post: {
platform: 'VK',
ownerId: '-22446688',
postId: '1054',
sourceUrl: 'https://vk.com/wall-22446688_1054',
title: 'Розыгрыш призов',
text: 'Текст поста',
likesCount: 150,
commentsCount: 50,
repostsCount: 20,
},
filterRules: DEFAULT_FILTER_RULES,
winnersCount: 1,
reserveWinnersCount: 1,
});
expect(gw.status).toBe('READY');
expect(gw.participants.length).toBe(0);
// 2. Save Participants
const updatedGw = await repo.saveParticipants(gw.id, sampleParticipants);
expect(updatedGw.participants.length).toBe(3);
// 3. Create & Lock Snapshot
const snapshot = await repo.createAndLockSnapshot(
gw.id,
sampleParticipants,
DEFAULT_FILTER_RULES
);
expect(snapshot.version).toBe(1);
expect(snapshot.participantCount).toBe(3);
expect(snapshot.participantsSnapshotHash).toBeDefined();
expect(snapshot.conditionsHash).toBeDefined();
// Verify giveaway status transitioned to SNAPSHOT_LOCKED
const lockedGw = await repo.getGiveawayById(gw.id);
expect(lockedGw?.status).toBe('SNAPSHOT_LOCKED');
// 4. Execute Draw on Snapshot
const seed = 'test-persistence-seed-2026';
const drawResult = executeDeterministicDrawV1({
giveawayId: gw.id,
snapshot,
totalLoadedCount: 3,
winnersCount: 1,
reserveWinnersCount: 1,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
expect(drawResult.winners.length).toBe(1);
expect(drawResult.reserveWinners.length).toBe(1);
expect(drawResult.auditHash).toBeDefined();
// 5. Persist DrawResult and Audit
const finishedGw = await repo.saveDrawResultAndAudit(gw.id, snapshot.id, drawResult);
expect(finishedGw.status).toBe('DRAWN');
expect(finishedGw.drawnAt).toBeDefined();
expect(finishedGw.drawResult).toBeDefined();
expect(finishedGw.drawResult?.winners.length).toBe(1);
// 6. Simulate Server Reload: Fetch directly by ID
const reloaded = await repo.getGiveawayById(gw.id);
expect(reloaded).not.toBeNull();
expect(reloaded?.status).toBe('DRAWN');
expect(reloaded?.snapshots.length).toBe(1);
expect(reloaded?.drawResult?.auditHash).toBe(drawResult.auditHash);
expect(reloaded?.drawResult?.winnerIds).toEqual(drawResult.winnerIds);
});
});

View file

@ -1,11 +1,13 @@
import { describe, it, expect } from 'vitest';
import { executeDeterministicDraw, verifyDrawResult } from '../src/core/randomizer/deterministic';
import { computeParticipantsSnapshotHash, generateRandomSeed } from '../src/core/randomizer/hasher';
import { executeDeterministicDrawV1, verifyDrawResult, ALGORITHM_VERSION_V1 } from '../src/core/randomizer/deterministic';
import { generateCryptoSecureSeed } from '../src/core/randomizer/hasher';
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical';
import { FilteredParticipant } from '../src/core/types/participant';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { ParticipantSnapshotData } from '../src/core/types/audit';
function createMockEligibleParticipants(count: number): FilteredParticipant[] {
return Array.from({ length: count }, (_, i) => ({
function createMockSnapshot(count: number): ParticipantSnapshotData {
const eligible: FilteredParticipant[] = Array.from({ length: count }, (_, i) => ({
platformUserId: `${1000 + i}`,
firstName: `User${i + 1}`,
lastName: `Surname${i + 1}`,
@ -15,21 +17,42 @@ function createMockEligibleParticipants(count: number): FilteredParticipant[] {
liked: true,
commented: true,
commentsCount: 1,
reposted: true,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
}));
return {
id: 'snap-test-1',
giveawayId: 'gw-test-1',
version: 1,
createdAt: new Date().toISOString(),
eligibleParticipants: eligible,
participantCount: count,
participantsSnapshotHash: computeParticipantsSnapshotHash(eligible),
conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES),
};
}
describe('Deterministic Randomizer & Provably Fair Engine', () => {
it('should generate identical winners given the same participants snapshot and seed', () => {
const participants = createMockEligibleParticipants(50);
describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => {
it('should use CSPRNG crypto.randomBytes for seed generation (no Math.random)', () => {
const seed1 = generateCryptoSecureSeed();
const seed2 = generateCryptoSecureSeed();
expect(seed1).toHaveLength(32); // 16 bytes in hex = 32 chars
expect(seed2).toHaveLength(32);
expect(seed1).not.toBe(seed2);
expect(/^[0-9a-f]{32}$/.test(seed1)).toBe(true);
});
it('should guarantee deterministic replay given the same snapshot and seed', () => {
const snapshot = createMockSnapshot(50);
const seed = 'test-secret-seed-2026';
const draw1 = executeDeterministicDraw({
const draw1 = executeDeterministicDrawV1({
giveawayId: 'gw-1',
eligibleParticipants: participants,
snapshot,
totalLoadedCount: 50,
winnersCount: 3,
reserveWinnersCount: 2,
@ -37,9 +60,9 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
filterRules: DEFAULT_FILTER_RULES,
});
const draw2 = executeDeterministicDraw({
const draw2 = executeDeterministicDrawV1({
giveawayId: 'gw-1',
eligibleParticipants: participants,
snapshot,
totalLoadedCount: 50,
winnersCount: 3,
reserveWinnersCount: 2,
@ -47,24 +70,26 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
filterRules: DEFAULT_FILTER_RULES,
});
expect(draw1.algorithmVersion).toBe(ALGORITHM_VERSION_V1);
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
expect(draw1.winnerIds).toEqual(draw2.winnerIds);
expect(draw1.reserveWinnerIds).toEqual(draw2.reserveWinnerIds);
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
draw2.winners.map(w => w.participant.platformUserId)
);
expect(draw1.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
draw2.reserveWinners.map(w => w.participant.platformUserId)
);
expect(draw1.verificationSignature).toBe(draw2.verificationSignature);
});
it('should produce different winners when seed changes', () => {
const participants = createMockEligibleParticipants(100);
const snapshot = createMockSnapshot(100);
const seedA = 'seed-alpha-123';
const seedB = 'seed-beta-456';
const drawA = executeDeterministicDraw({
const drawA = executeDeterministicDrawV1({
giveawayId: 'gw-a',
eligibleParticipants: participants,
snapshot,
totalLoadedCount: 100,
winnersCount: 5,
reserveWinnersCount: 2,
@ -72,9 +97,9 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
filterRules: DEFAULT_FILTER_RULES,
});
const drawB = executeDeterministicDraw({
const drawB = executeDeterministicDrawV1({
giveawayId: 'gw-b',
eligibleParticipants: participants,
snapshot,
totalLoadedCount: 100,
winnersCount: 5,
reserveWinnersCount: 2,
@ -82,19 +107,16 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
filterRules: DEFAULT_FILTER_RULES,
});
const winnersA = drawA.winners.map(w => w.participant.platformUserId);
const winnersB = drawB.winners.map(w => w.participant.platformUserId);
expect(winnersA).not.toEqual(winnersB);
expect(drawA.winnerIds).not.toEqual(drawB.winnerIds);
});
it('should guarantee no duplicates between winners and reserve winners', () => {
const participants = createMockEligibleParticipants(30);
const seed = generateRandomSeed();
const snapshot = createMockSnapshot(30);
const seed = generateCryptoSecureSeed();
const draw = executeDeterministicDraw({
const draw = executeDeterministicDrawV1({
giveawayId: 'gw-uniq',
eligibleParticipants: participants,
snapshot,
totalLoadedCount: 30,
winnersCount: 5,
reserveWinnersCount: 5,
@ -102,22 +124,18 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
filterRules: DEFAULT_FILTER_RULES,
});
const allChosenIds = [
...draw.winners.map(w => w.participant.platformUserId),
...draw.reserveWinners.map(w => w.participant.platformUserId),
];
const allChosenIds = [...draw.winnerIds, ...draw.reserveWinnerIds];
const uniqueIds = new Set(allChosenIds);
expect(uniqueIds.size).toBe(10);
});
it('should handle cases where pool size is smaller than requested winners', () => {
const participants = createMockEligibleParticipants(2);
it('should handle small pool sizes gracefully', () => {
const snapshot = createMockSnapshot(2);
const seed = 'small-pool-seed';
const draw = executeDeterministicDraw({
const draw = executeDeterministicDrawV1({
giveawayId: 'gw-small',
eligibleParticipants: participants,
snapshot,
totalLoadedCount: 2,
winnersCount: 5,
reserveWinnersCount: 3,
@ -129,59 +147,13 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
expect(draw.reserveWinners.length).toBe(0);
});
it('should throw when attempting draw with 0 eligible participants', () => {
expect(() => {
executeDeterministicDraw({
giveawayId: 'gw-empty',
eligibleParticipants: [],
totalLoadedCount: 0,
winnersCount: 1,
reserveWinnersCount: 0,
seed: 'empty-seed',
filterRules: DEFAULT_FILTER_RULES,
});
}).toThrow(/Cannot conduct draw with 0 eligible participants/);
});
it('should be independent of input participant ordering (canonical sorting)', () => {
const p1 = createMockEligibleParticipants(20);
const p2 = [...p1].reverse(); // reversed order
const seed = 'sort-order-invariant-seed';
const draw1 = executeDeterministicDraw({
giveawayId: 'gw-sort',
eligibleParticipants: p1,
totalLoadedCount: 20,
winnersCount: 3,
reserveWinnersCount: 1,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
const draw2 = executeDeterministicDraw({
giveawayId: 'gw-sort',
eligibleParticipants: p2,
totalLoadedCount: 20,
winnersCount: 3,
reserveWinnersCount: 1,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
draw2.winners.map(w => w.participant.platformUserId)
);
});
it('should allow third-party verification through verifyDrawResult', () => {
const participants = createMockEligibleParticipants(25);
it('should allow third-party audit replay verification via verifyDrawResult', () => {
const snapshot = createMockSnapshot(25);
const seed = 'audit-verification-seed';
const originalDraw = executeDeterministicDraw({
const originalDraw = executeDeterministicDrawV1({
giveawayId: 'gw-audit',
eligibleParticipants: participants,
snapshot,
totalLoadedCount: 25,
winnersCount: 2,
reserveWinnersCount: 2,
@ -189,14 +161,12 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
filterRules: DEFAULT_FILTER_RULES,
});
const verification = verifyDrawResult(participants, seed, 2, 2);
const verification = verifyDrawResult(snapshot, seed, 2, 2, ALGORITHM_VERSION_V1);
expect(verification.snapshotHash).toBe(originalDraw.participantsSnapshotHash);
expect(verification.winnerIds).toEqual(originalDraw.winnerIds);
expect(verification.reserveWinnerIds).toEqual(originalDraw.reserveWinnerIds);
expect(verification.winners.map(w => w.participant.platformUserId)).toEqual(
originalDraw.winners.map(w => w.participant.platformUserId)
);
expect(verification.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
originalDraw.reserveWinners.map(w => w.participant.platformUserId)
);
});
});

View file

@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import {
computeConditionsHash,
computeParticipantsSnapshotHash,
canonicalStringify
} from '../src/core/randomizer/canonical';
import { FilterRules } from '../src/core/types/giveaway';
import { FilteredParticipant } from '../src/core/types/participant';
describe('Snapshot Immutability & Canonical Hashing', () => {
const baseRules: FilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: true,
excludeAdmins: true,
excludeBlacklistedIds: ['100', '200'],
excludeDuplicateComments: true,
minEligibleParticipants: 1,
};
const sampleParticipants: FilteredParticipant[] = [
{
platformUserId: '101',
firstName: 'Иван',
lastName: 'Иванов',
username: 'ivanov',
source: 'LIKES',
liked: true,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
{
platformUserId: '102',
firstName: 'Анна',
lastName: 'Смирнова',
username: 'anna_s',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
];
it('should generate identical conditionsHash regardless of key order in rules object', () => {
const rulesA = { ...baseRules };
const rulesB: FilterRules = {
minEligibleParticipants: 1,
excludeDuplicateComments: true,
excludeBlacklistedIds: ['200', '100'], // reversed array
excludeAdmins: true,
requireSubscription: true,
requireRepost: false,
requireComment: false,
requireLike: true,
};
const hashA = computeConditionsHash(rulesA);
const hashB = computeConditionsHash(rulesB);
expect(hashA).toBe(hashB);
});
it('should change conditionsHash when any rule changes', () => {
const originalHash = computeConditionsHash(baseRules);
const changedLike = computeConditionsHash({ ...baseRules, requireLike: false });
const changedComment = computeConditionsHash({ ...baseRules, requireComment: true });
const changedBlacklist = computeConditionsHash({ ...baseRules, excludeBlacklistedIds: ['300'] });
expect(changedLike).not.toBe(originalHash);
expect(changedComment).not.toBe(originalHash);
expect(changedBlacklist).not.toBe(originalHash);
});
it('should generate invariant snapshot hash regardless of participant list ordering', () => {
const p1 = [...sampleParticipants];
const p2 = [...sampleParticipants].reverse();
const hash1 = computeParticipantsSnapshotHash(p1);
const hash2 = computeParticipantsSnapshotHash(p2);
expect(hash1).toBe(hash2);
});
it('should change snapshot hash when participant attributes or actions change', () => {
const originalHash = computeParticipantsSnapshotHash(sampleParticipants);
const modifiedParticipants: FilteredParticipant[] = [
{
...sampleParticipants[0],
firstName: 'Иван Измененный',
},
sampleParticipants[1],
];
const newHash = computeParticipantsSnapshotHash(modifiedParticipants);
expect(newHash).not.toBe(originalHash);
});
it('should produce valid canonical string for deep objects', () => {
const obj1 = { z: 1, a: { y: 2, b: 3 } };
const obj2 = { a: { b: 3, y: 2 }, z: 1 };
expect(canonicalStringify(obj1)).toBe(canonicalStringify(obj2));
expect(canonicalStringify(obj1)).toBe('{"a":{"b":3,"y":2},"z":1}');
});
});

View file

@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { executeParticipantPipeline } from '../src/core/pipeline/participant-enricher';
import { RawParticipant } from '../src/core/types/participant';
import { FilterRules } from '../src/core/types/giveaway';
import { SocialMediaProvider } from '../src/providers/types';
describe('Subscription Enrichment Pipeline', () => {
const mockRawParticipants: RawParticipant[] = [
{
platformUserId: '1',
firstName: 'Пользователь',
lastName: 'Один',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: false, // Initially false
},
{
platformUserId: '2',
firstName: 'Пользователь',
lastName: 'Два',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: false, // Initially false
},
];
const mockProvider: SocialMediaProvider = {
platform: 'VK',
capabilities: {
likes: true,
comments: true,
reposts: false,
subscriptions: true,
adminDetection: false,
},
parsePostUrl: () => ({ ownerId: '-100', postId: '200' }),
fetchPost: async () => ({} as any),
fetchParticipants: async () => [],
checkSubscription: async (userIds: string[], groupId: string) => {
const map = new Map<string, boolean>();
map.set('1', true); // User 1 is subscribed
map.set('2', false); // User 2 is not subscribed
return map;
},
};
it('should enrich subscribed status and filter out non-subscribed users when requireSubscription is true', async () => {
const rules: FilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: true,
targetGroupId: '-100',
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = await executeParticipantPipeline({
rawParticipants: mockRawParticipants,
rules,
provider: mockProvider,
ownerId: '-100',
});
expect(result.stats.total).toBe(2);
expect(result.stats.eligibleCount).toBe(1);
expect(result.stats.excludedCount).toBe(1);
const eligible = result.eligibleParticipants[0];
expect(eligible.platformUserId).toBe('1');
expect(eligible.subscribed).toBe(true);
const excluded = result.excludedParticipants[0];
expect(excluded.platformUserId).toBe('2');
expect(excluded.subscribed).toBe(false);
expect(excluded.exclusionReason).toContain('NOT_SUBSCRIBED');
});
it('should bypass subscription enrichment if requireSubscription is false', async () => {
const rules: FilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = await executeParticipantPipeline({
rawParticipants: mockRawParticipants,
rules,
provider: mockProvider,
ownerId: '-100',
});
expect(result.stats.eligibleCount).toBe(2);
expect(result.eligibleParticipants.every(p => p.eligible)).toBe(true);
});
});

View file

@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { DeterministicHmacStream } from '../src/core/randomizer/unbiased-sampler';
describe('DeterministicHmacStream & Unbiased Rejection Sampling', () => {
it('should generate deterministic uint32 sequence for the same seed and context', () => {
const stream1 = new DeterministicHmacStream('seed-123', 'ctx-abc');
const stream2 = new DeterministicHmacStream('seed-123', 'ctx-abc');
const seq1 = [stream1.nextUint32(), stream1.nextUint32(), stream1.nextUint32()];
const seq2 = [stream2.nextUint32(), stream2.nextUint32(), stream2.nextUint32()];
expect(seq1).toEqual(seq2);
});
it('should generate different sequence when context or seed changes', () => {
const streamA = new DeterministicHmacStream('seed-123', 'ctx-abc');
const streamB = new DeterministicHmacStream('seed-456', 'ctx-abc');
expect(streamA.nextUint32()).not.toBe(streamB.nextUint32());
});
it('should strictly sample integers within the requested range', () => {
const stream = new DeterministicHmacStream('test-seed-range', 'range-check');
const range = 7;
for (let i = 0; i < 500; i++) {
const val = stream.sampleUnbiasedIndex(range);
expect(val).toBeGreaterThanOrEqual(0);
expect(val).toBeLessThan(range);
}
});
it('should demonstrate uniform distribution without modulo bias', () => {
const stream = new DeterministicHmacStream('uniform-dist-seed', 'bias-test');
const range = 5;
const iterations = 10000;
const counts = [0, 0, 0, 0, 0];
for (let i = 0; i < iterations; i++) {
const idx = stream.sampleUnbiasedIndex(range);
counts[idx]++;
}
const expected = iterations / range; // 2000
// Each bucket should be close to expected within +/- 10%
for (let i = 0; i < range; i++) {
const deviation = Math.abs(counts[i] - expected) / expected;
expect(deviation).toBeLessThan(0.10);
}
});
it('should handle edge cases: range = 1 and invalid range <= 0', () => {
const stream = new DeterministicHmacStream('edge-seed', 'edge-test');
expect(stream.sampleUnbiasedIndex(1)).toBe(0);
expect(() => stream.sampleUnbiasedIndex(0)).toThrow(/Invalid sampling range/);
expect(() => stream.sampleUnbiasedIndex(-5)).toThrow(/Invalid sampling range/);
});
});