|
| 1 | +import { |
| 2 | + Entity, |
| 3 | + PrimaryGeneratedColumn, |
| 4 | + Column, |
| 5 | + CreateDateColumn, |
| 6 | + Index, |
| 7 | + ManyToOne, |
| 8 | + JoinColumn, |
| 9 | + Unique, |
| 10 | +} from 'typeorm'; |
| 11 | + |
| 12 | +/** |
| 13 | + * Status of a puzzle solution submission attempt |
| 14 | + */ |
| 15 | +export enum SolutionAttemptStatus { |
| 16 | + CORRECT = 'correct', |
| 17 | + INCORRECT = 'incorrect', |
| 18 | + TIMEOUT = 'timeout', |
| 19 | + FRAUD_DETECTED = 'fraud_detected', |
| 20 | + RATE_LIMITED = 'rate_limited', |
| 21 | +} |
| 22 | + |
| 23 | +/** |
| 24 | + * Tracks every puzzle solution submission attempt. |
| 25 | + * Answers are stored as SHA-256 hashes — plaintext answers are never persisted. |
| 26 | + * The `nonce` column has a unique constraint to prevent replay attacks. |
| 27 | + */ |
| 28 | +@Entity('puzzle_solution_attempts') |
| 29 | +@Unique(['nonce']) // Anti-replay: nonce must be globally unique |
| 30 | +@Index(['userId', 'puzzleId']) |
| 31 | +@Index(['userId', 'createdAt']) |
| 32 | +@Index(['puzzleId', 'status']) |
| 33 | +export class PuzzleSolutionAttempt { |
| 34 | + @PrimaryGeneratedColumn('uuid') |
| 35 | + id: string; |
| 36 | + |
| 37 | + // ──────────────── Identifiers ──────────────── |
| 38 | + |
| 39 | + @Column({ type: 'uuid' }) |
| 40 | + @Index() |
| 41 | + userId: string; |
| 42 | + |
| 43 | + @Column({ type: 'uuid' }) |
| 44 | + @Index() |
| 45 | + puzzleId: string; |
| 46 | + |
| 47 | + // ──────────────── Anti-Replay ──────────────── |
| 48 | + |
| 49 | + /** |
| 50 | + * Client-generated UUID v4 that must be unique per submission. |
| 51 | + * Stored to detect replay attacks (re-submitting same request). |
| 52 | + */ |
| 53 | + @Column({ type: 'varchar', length: 36 }) |
| 54 | + @Index() |
| 55 | + nonce: string; |
| 56 | + |
| 57 | + // ──────────────── Verification ──────────────── |
| 58 | + |
| 59 | + /** |
| 60 | + * SHA-256 hash of the normalised submitted answer. |
| 61 | + * Never store the plaintext answer. |
| 62 | + */ |
| 63 | + @Column({ type: 'varchar', length: 64 }) |
| 64 | + answerHash: string; |
| 65 | + |
| 66 | + @Column({ |
| 67 | + type: 'enum', |
| 68 | + enum: SolutionAttemptStatus, |
| 69 | + default: SolutionAttemptStatus.INCORRECT, |
| 70 | + }) |
| 71 | + @Index() |
| 72 | + status: SolutionAttemptStatus; |
| 73 | + |
| 74 | + // ──────────────── Timing ──────────────── |
| 75 | + |
| 76 | + /** |
| 77 | + * ISO timestamp sent by the client indicating when they received |
| 78 | + * (started) the puzzle. Used for time-limit and fraud validation. |
| 79 | + */ |
| 80 | + @Column({ type: 'timestamp with time zone' }) |
| 81 | + sessionStartedAt: Date; |
| 82 | + |
| 83 | + /** |
| 84 | + * Elapsed seconds between sessionStartedAt and submission time. |
| 85 | + * Computed server-side — not trusted from the client. |
| 86 | + */ |
| 87 | + @Column({ type: 'int', default: 0 }) |
| 88 | + timeTakenSeconds: number; |
| 89 | + |
| 90 | + // ──────────────── Performance ──────────────── |
| 91 | + |
| 92 | + @Column({ type: 'int', default: 0 }) |
| 93 | + hintsUsed: number; |
| 94 | + |
| 95 | + // ──────────────── Rewards ──────────────── |
| 96 | + |
| 97 | + @Column({ type: 'int', default: 0 }) |
| 98 | + scoreAwarded: number; |
| 99 | + |
| 100 | + /** |
| 101 | + * Detailed reward breakdown: base points, bonuses, penalties, achievements. |
| 102 | + */ |
| 103 | + @Column({ type: 'jsonb', default: {} }) |
| 104 | + rewardData: { |
| 105 | + baseScore?: number; |
| 106 | + timeBonus?: number; |
| 107 | + streakBonus?: number; |
| 108 | + hintPenalty?: number; |
| 109 | + firstSolveBonus?: number; |
| 110 | + achievements?: string[]; |
| 111 | + totalExperience?: number; |
| 112 | + }; |
| 113 | + |
| 114 | + // ──────────────── Fraud / Anti-Cheat ──────────────── |
| 115 | + |
| 116 | + /** |
| 117 | + * Fraud/anti-cheat flags recorded during processing. |
| 118 | + * Submission may still be stored when fraud is detected for audit purposes. |
| 119 | + */ |
| 120 | + @Column({ type: 'jsonb', default: {} }) |
| 121 | + fraudFlags: { |
| 122 | + tooFast?: boolean; |
| 123 | + minExpectedSeconds?: number; |
| 124 | + actualSeconds?: number; |
| 125 | + violationTypes?: string[]; |
| 126 | + riskScore?: number; |
| 127 | + }; |
| 128 | + |
| 129 | + // ──────────────── Request Metadata ──────────────── |
| 130 | + |
| 131 | + @Column({ type: 'varchar', length: 45, nullable: true }) |
| 132 | + ipAddress?: string; |
| 133 | + |
| 134 | + /** |
| 135 | + * Extensible metadata (user-agent, device fingerprint, geo, etc.) |
| 136 | + */ |
| 137 | + @Column({ type: 'jsonb', default: {} }) |
| 138 | + metadata: Record<string, any>; |
| 139 | + |
| 140 | + @CreateDateColumn() |
| 141 | + @Index() |
| 142 | + createdAt: Date; |
| 143 | +} |
0 commit comments