import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts"; export interface RedisCaptchaClient { get(key: string): Promise | string | null; set( key: string, value: string, options?: { px?: number; nx?: boolean }, ): Promise | unknown; del(key: string): Promise | number; eval?( script: string, options: { keys: string[]; arguments: string[] }, ): Promise | unknown; scanIterator?(options?: { match?: string; count?: number }): AsyncIterable; } export interface RedisCaptchaStoreOptions { prefix?: string; } const MUTATE_CHALLENGE = ` local raw = redis.call('GET', KEYS[1]) if not raw then return nil end local value = cjson.decode(raw) local now = tonumber(ARGV[1]) if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end if ARGV[2] == 'attempt' then value.attempts = tonumber(value.attempts) + 1 else value.consumedAt = now end local encoded = cjson.encode(value) local ttl = math.max(1, tonumber(value.expiresAt) - now) redis.call('SET', KEYS[1], encoded, 'PX', ttl) return encoded `; const CONSUME_TOKEN = ` local raw = redis.call('GET', KEYS[1]) if not raw then return nil end local value = cjson.decode(raw) local now = tonumber(ARGV[1]) if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end value.consumedAt = now local encoded = cjson.encode(value) local ttl = math.max(1, tonumber(value.expiresAt) - now) redis.call('SET', KEYS[1], encoded, 'PX', ttl) return encoded `; export class RedisCaptchaStore implements CaptchaStore { private readonly prefix: string; private readonly locks = new Map>(); constructor( private readonly redis: RedisCaptchaClient, options: RedisCaptchaStoreOptions = {}, ) { this.prefix = options.prefix ?? "wrn:captcha:"; } async createChallenge(record: CaptchaChallengeRecord): Promise { const ttl = Math.max(1, record.expiresAt - Date.now()); await this.redis.set(this.challengeKey(record.id), JSON.stringify(record), { px: ttl }); } async getChallenge(id: string): Promise { return this.read(this.challengeKey(id)); } async incrementAttempts(id: string, now: number): Promise { return this.mutateChallenge(id, now, "attempt"); } async consumeChallenge(id: string, now: number): Promise { return this.mutateChallenge(id, now, "consume"); } async deleteChallenge(id: string): Promise { await this.redis.del(this.challengeKey(id)); } async createToken(record: CaptchaResponseTokenRecord): Promise { const ttl = Math.max(1, record.expiresAt - Date.now()); await this.redis.set(this.tokenKey(record.tokenHash), JSON.stringify(record), { px: ttl }); } async getToken(tokenHash: string): Promise { return this.read(this.tokenKey(tokenHash)); } async consumeToken( tokenHash: string, now: number, ): Promise { const key = this.tokenKey(tokenHash); if (this.redis.eval) { const raw = await this.redis.eval(CONSUME_TOKEN, { keys: [key], arguments: [String(now)], }); return typeof raw === "string" ? (JSON.parse(raw) as CaptchaResponseTokenRecord) : undefined; } return this.withLock(key, async () => { const current = await this.read(key); if (!current || current.expiresAt <= now || current.consumedAt) return undefined; current.consumedAt = now; await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now), }); return current; }); } async deleteToken(tokenHash: string): Promise { await this.redis.del(this.tokenKey(tokenHash)); } async gc(): Promise { // Redis TTLs are authoritative, so no explicit sweep is required. } private async mutateChallenge( id: string, now: number, operation: "attempt" | "consume", ): Promise { const key = this.challengeKey(id); if (this.redis.eval) { const raw = await this.redis.eval(MUTATE_CHALLENGE, { keys: [key], arguments: [String(now), operation], }); return typeof raw === "string" ? (JSON.parse(raw) as CaptchaChallengeRecord) : undefined; } return this.withLock(key, async () => { const current = await this.read(key); if (!current || current.expiresAt <= now || current.consumedAt) return undefined; if (operation === "attempt") current.attempts += 1; else current.consumedAt = now; await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now), }); return current; }); } private async read(key: string): Promise { const value = await this.redis.get(key); return value ? (JSON.parse(value) as T) : undefined; } private challengeKey(id: string): string { return `${this.prefix}challenge:${id}`; } private tokenKey(hash: string): string { return `${this.prefix}token:${hash}`; } private async withLock(key: string, task: () => Promise): Promise { const previous = this.locks.get(key) ?? Promise.resolve(); let release!: () => void; const current = new Promise((resolve) => { release = resolve; }); const chain = previous.then(() => current); this.locks.set(key, chain); await previous; try { return await task(); } finally { release(); if (this.locks.get(key) === chain) this.locks.delete(key); } } } export function createRedisCaptchaStore( redis: RedisCaptchaClient, options?: RedisCaptchaStoreOptions, ): RedisCaptchaStore { return new RedisCaptchaStore(redis, options); }