import { createHash, createHmac, randomBytes } from 'node:crypto' // Deterministic shuffle for the prize raffle. Given the same (seed, sorted // candidate list), produces the exact same winner ordering — so a stored // (seed, candidatesJson) pair on a PrizeDraw row reproduces the draw. // // Construction: HMAC-SHA256 stream PRNG (seed acts as key, counter as // message). Each 4-byte block consumed via rejection sampling to remove // modulo bias — equivalent to what crypto.randomInt does internally, but // deterministic given the seed. /** 32-byte CSPRNG seed, hex-encoded. Use this to start a new draw. */ export function generateSeed(): string { return randomBytes(32).toString('hex') } /** * SHA-256 hex of the sorted userId list. Pins the input set so the verify * endpoint can detect when the eligible pool changed between draw + verify. */ export function hashCandidates(userIds: readonly string[]): string { const sorted = [...userIds].sort() return createHash('sha256').update(sorted.join('\n')).digest('hex') } interface ByteStream { nextByte(): number } function createSeededByteStream(seedHex: string): ByteStream { const seed = Buffer.from(seedHex, 'hex') if (seed.length === 0) throw new Error('seed must be non-empty hex') let counter = 0 let buffer: Buffer = Buffer.alloc(0) let offset = 0 function refill() { const ctr = Buffer.alloc(4) ctr.writeUInt32BE(counter >>> 0, 0) buffer = createHmac('sha256', seed).update(ctr).digest() offset = 0 counter += 1 } return { nextByte() { if (offset >= buffer.length) refill() return buffer[offset++] }, } } /** Unbiased random integer in [0, maxExclusive) from the seeded stream. */ function unbiasedInt(stream: ByteStream, maxExclusive: number): number { if (!Number.isInteger(maxExclusive) || maxExclusive <= 0) { throw new Error('maxExclusive must be a positive integer') } // 2^32 chunks, rejection-sample to remove modulo bias. const range = 0x1_0000_0000 const limit = range - (range % maxExclusive) for (;;) { const val = (stream.nextByte() * 0x1_00_00_00 + stream.nextByte() * 0x1_00_00 + stream.nextByte() * 0x1_00 + stream.nextByte()) >>> 0 if (val < limit) return val % maxExclusive } } /** * Fisher–Yates shuffle driven by the seeded stream. The input list is sorted * before shuffling so the algorithm is invariant to the caller's iteration * order (DB row order isn't stable across queries). * * Returns a new array — input is not mutated. */ export function seededShuffle( candidates: readonly string[], seedHex: string, ): string[] { const arr = [...candidates].sort() const stream = createSeededByteStream(seedHex) for (let i = arr.length - 1; i > 0; i--) { const j = unbiasedInt(stream, i + 1) const tmp = arr[i] arr[i] = arr[j] arr[j] = tmp } return arr }