import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore, } from "../types.ts"; function cloneChallenge(record: CaptchaChallengeRecord): CaptchaChallengeRecord { return structuredClone(record); } function cloneToken(record: CaptchaResponseTokenRecord): CaptchaResponseTokenRecord { return structuredClone(record); } export interface MemoryCaptchaStoreOptions { maxChallenges?: number; maxTokens?: number; } export class MemoryCaptchaStore implements CaptchaStore { private readonly challenges = new Map(); private readonly tokens = new Map(); private readonly maxChallenges: number; private readonly maxTokens: number; constructor(options: MemoryCaptchaStoreOptions = {}) { this.maxChallenges = options.maxChallenges ?? 25_000; this.maxTokens = options.maxTokens ?? 50_000; if (!Number.isInteger(this.maxChallenges) || this.maxChallenges < 1) { throw new RangeError("maxChallenges must be a positive integer"); } if (!Number.isInteger(this.maxTokens) || this.maxTokens < 1) { throw new RangeError("maxTokens must be a positive integer"); } } async createChallenge(record: CaptchaChallengeRecord): Promise { this.evict(this.challenges, this.maxChallenges, record.createdAt); this.challenges.set(record.id, cloneChallenge(record)); } async getChallenge(id: string): Promise { const record = this.challenges.get(id); return record ? cloneChallenge(record) : undefined; } async incrementAttempts(id: string, now: number): Promise { const record = this.challenges.get(id); if (!record || record.expiresAt <= now || record.consumedAt) return undefined; record.attempts += 1; return cloneChallenge(record); } async consumeChallenge(id: string, now: number): Promise { const record = this.challenges.get(id); if (!record || record.expiresAt <= now || record.consumedAt) return undefined; record.consumedAt = now; return cloneChallenge(record); } async deleteChallenge(id: string): Promise { this.challenges.delete(id); } async createToken(record: CaptchaResponseTokenRecord): Promise { this.evict(this.tokens, this.maxTokens, record.createdAt); this.tokens.set(record.tokenHash, cloneToken(record)); } async getToken(tokenHash: string): Promise { const record = this.tokens.get(tokenHash); return record ? cloneToken(record) : undefined; } async consumeToken(tokenHash: string, now: number): Promise { const record = this.tokens.get(tokenHash); if (!record || record.expiresAt <= now || record.consumedAt) return undefined; record.consumedAt = now; return cloneToken(record); } async deleteToken(tokenHash: string): Promise { this.tokens.delete(tokenHash); } async gc(now: number): Promise { for (const [id, record] of this.challenges) { if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) { this.challenges.delete(id); } } for (const [hash, record] of this.tokens) { if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) { this.tokens.delete(hash); } } } private evict(map: Map, max: number, now: number): void { for (const [key, record] of map) if (record.expiresAt <= now) map.delete(key); while (map.size >= max) map.delete(map.keys().next().value!); } } export function createMemoryCaptchaStore(options?: MemoryCaptchaStoreOptions): MemoryCaptchaStore { return new MemoryCaptchaStore(options); }