/* * Copyright (C) 2024-present Puter Technologies Inc. * * This file is part of Puter. * * Puter is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /** * SMS-to-card fallback: the shared rule for whether a phone-gated user has run * out of SMS attempts and may verify a card instead. * * Two consumers need the same answer, so it lives here rather than inside the * controller: `/send-confirm-phone` (which counts attempts and opens the * fallback) and `/whoami` (which tells a reloading GUI the fallback is already * open — the send route can't, because by then every send is rejected by its * own rate limit before any handler runs). */ import type { EventClient } from '../clients/event/EventClient'; import type { PreludeClient } from '../clients/prelude/PreludeClient'; import type { IConfig } from '../types'; import { BoundedTtlMap } from './boundedTtlMap'; /** * `/send-confirm-phone` route rate limit: how many verification texts one * account can ask for per window. This is also the definition of "out of SMS * attempts" — the fallback opens on the last send this allows, because requests * past it are rejected in middleware and never reach a handler. */ export const SEND_PHONE_RATE_LIMIT = 10; export const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000; /** * How long the fallback stays open once it opens. Deliberately much longer than * the attempt counter's window: the counter exists to detect exhaustion, and * once detected the user needs time to finish the card flow (and to come back * to it) without racing the counter's expiry. */ export const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60; /** Attempt counter, TTL-tied to the send rate-limit window. */ export const phoneAttemptsKey = (userId: number): string => `phone-verify-attempts:${userId}`; /** Eligibility flag, the only thing the card endpoints read. */ export const cardFallbackFlagKey = (userId: number): string => `card-fallback-open:${userId}`; /** The user fields the rule reads. */ export interface PhoneGatedUser { id?: number | null; requires_phone_verification?: boolean | number | null; } /** Reads one system-KV key; resolves whatever was stored (or null/undefined). */ export type ReadKvFlag = (key: string) => Promise; /** * How many SMS send attempts open the fallback. Defaults to the full send * allowance — the fallback is meant to appear only once SMS has actually run * out for this user, not as a competing option alongside a working SMS flow. * * A lower `after_attempts` is honoured (it makes the card path reachable * without burning ten texts, which is what QA wants), and any value above the * send allowance is clamped down to it: requests past the route limit are * rejected in middleware and never reach the attempt counter, so a higher * threshold could never be crossed. */ export function cardFallbackAfterAttempts( config: Pick, ): number { const cfg = config.phone_verification_card_fallback; return Math.min( typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0 ? cfg.after_attempts : SEND_PHONE_RATE_LIMIT, SEND_PHONE_RATE_LIMIT, ); } export interface CardFallbackDeps { /** Whether an SMS provider is set up. */ smsConfigured: () => boolean; /** * Whether the owning extension reports the card gate on; null when none is * installed. */ probeCardVerification: () => Promise; } /** The clients that answer {@link CardFallbackDeps}. */ export interface CardFallbackClients { prelude?: Pick | null; event?: Pick | null; } /** * Deps over the live clients. `clients` is read at call time, not captured, so * a client swapped after construction (tests) is what gets asked. */ export function cardFallbackDepsFrom( clients: CardFallbackClients, ): CardFallbackDeps { return { smsConfigured: () => Boolean(clients.prelude?.isConfigured()), probeCardVerification: async () => { const status: { enabled: boolean | null } = { enabled: null }; await clients.event?.emitAndWait( 'puter.card-verification.status', status, {}, ); return status.enabled; }, }; } /** * Memoized answer to the card-gate probe. The probe is side-effect free, but it * is asked on a polled endpoint, so cache it briefly rather than fanning out an * event per request. Only the _default_ is cached: an explicit `enabled: false` * short-circuits below without ever consulting this, so the operator's kill * switch still takes effect immediately. */ const CARD_STATUS_TTL_MS = 60_000; const cardStatusCache = new BoundedTtlMap<'status', boolean | null>({ maxEntries: 1, ttlMs: CARD_STATUS_TTL_MS, }); /** Drops the memoized probe answer. For tests, and for a config reload. */ export function resetCardVerificationStatusCache(): void { cardStatusCache.clear(); } /** * Whether an installed extension reports the card gate on; null when nothing * answers. Memoized per {@link CARD_STATUS_TTL_MS}. */ export async function isCardVerificationEnabled( deps: CardFallbackDeps, ): Promise { const cached = cardStatusCache.get('status'); if (cached !== undefined) return cached; let enabled: boolean | null = null; try { enabled = await deps.probeCardVerification(); } catch (e) { // Treat a broken probe as "no card gate": offering a card path that may // not work is worse than not offering one. console.warn('[card-verification] status probe failed:', e); } cardStatusCache.set('status', enabled); return enabled; } /** * Whether the SMS-to-card fallback is switched on. * * `enabled` is a tri-state. Set explicitly, it wins either way — that is the * opt-out. Left unset, the fallback follows the pair it bridges: on wherever * both halves actually work, off otherwise. Without that conjunction the offer * could show up on a deployment with no card gate behind it, where taking it * strands the user on a dialog that can only fail. */ export async function isCardFallbackEnabled( config: Pick, deps: CardFallbackDeps, ): Promise { const configured = config.phone_verification_card_fallback?.enabled; if (typeof configured === 'boolean') return configured; if (!deps.smsConfigured()) return false; return (await isCardVerificationEnabled(deps)) === true; } /** * Whether this user may verify a card in place of the phone gate right now. * * Reads only the eligibility flag, never the raw attempt counter: the counter * expires with the send rate-limit window, so deriving eligibility from it * would revoke the offer mid-flow. The cheap disqualifiers (not phone-gated, * feature off) come first, so an ordinary `/whoami` never reaches the KV read. * Every KV failure fails closed. */ export async function isCardFallbackEligible( config: Pick, user: PhoneGatedUser, readFlag: ReadKvFlag, deps: CardFallbackDeps, ): Promise { if (!user.requires_phone_verification) return false; if (typeof user.id !== 'number') return false; if (!(await isCardFallbackEnabled(config, deps))) return false; try { return (await readFlag(cardFallbackFlagKey(user.id))) === true; } catch (e) { console.warn('[card-verification] fallback flag read failed:', e); return false; } }