From 684d6752f9bf0907d95038594f4b0c0dbc2b15a6 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 23 Aug 2026 21:49:35 -0700 Subject: [PATCH] fix: provide fallback for signup verification (#3631) --- extensions/whoami.test.ts | 45 +++++ extensions/whoami.ts | 28 +++ package-lock.json | 2 +- src/backend/clients/event/types.ts | 17 +- .../controllers/auth/AuthController.test.ts | 170 +++++++++++++++- .../controllers/auth/AuthController.ts | 95 +++++---- src/backend/types.ts | 39 ++-- src/backend/util/cardFallback.ts | 181 ++++++++++++++++++ .../UI/UIWindowPhoneVerificationRequired.js | 34 +++- src/gui/src/initgui.js | 8 + src/puter-js/package-lock.json | 4 +- src/puter-js/package.json | 2 +- 12 files changed, 534 insertions(+), 91 deletions(-) create mode 100644 src/backend/util/cardFallback.ts diff --git a/extensions/whoami.test.ts b/extensions/whoami.test.ts index 9efa08380..8b54febfd 100644 --- a/extensions/whoami.test.ts +++ b/extensions/whoami.test.ts @@ -9,6 +9,7 @@ import { vi, } from 'vitest'; import { runWithContext } from '../src/backend/core/context.ts'; +import { configContainer } from '../src/backend/exports.ts'; import { PuterServer } from '../src/backend/server.ts'; import { setupTestServer } from '../src/backend/testUtil.ts'; import { handleWhoami } from './whoami.ts'; @@ -118,6 +119,50 @@ describe('whoami extension — handleWhoami', () => { expect(body).toHaveProperty('taskbar_items'); }); + it('reports the SMS-to-card fallback only once a send has opened it', async () => { + const user = await server.stores.user.create({ + username: `wuser_${Math.random().toString(36).slice(2, 8)}`, + uuid: uuidv4(), + password: 'hashedpw', + email: `${Math.random().toString(36).slice(2, 8)}@example.com`, + requires_phone_verification: true, + } as never); + const prev = configContainer.phone_verification_card_fallback; + configContainer.phone_verification_card_fallback = { + enabled: true, + } as never; + try { + const before = makeRes(); + await runWithContext( + { actor: { user: { uuid: user.uuid, id: user.id as number } } }, + () => handleWhoami(makeReq(), before.res), + ); + // Phone-gated, but the user has attempts left — no offer yet. + expect( + before.captured.body as Record, + ).toMatchObject({ card_fallback_available: false }); + + // Exhausting SMS attempts stamps this flag; whoami is then the only + // thing that can still tell a reloading GUI about the offer, since + // further sends are rejected by the route's own rate limit. + await server.stores.kv.set({ + key: `card-fallback-open:${user.id}`, + value: true, + }); + + const after = makeRes(); + await runWithContext( + { actor: { user: { uuid: user.uuid, id: user.id as number } } }, + () => handleWhoami(makeReq(), after.res), + ); + expect( + after.captured.body as Record, + ).toMatchObject({ card_fallback_available: true }); + } finally { + configContainer.phone_verification_card_fallback = prev; + } + }); + it('only forwards allow-listed feature flags', async () => { const user = await seedUser(); const { res, captured } = makeRes(); diff --git a/extensions/whoami.ts b/extensions/whoami.ts index 691374ab1..b59a28268 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -1,5 +1,6 @@ import { Context } from '@heyputer/backend/src/core'; import { extension } from '@heyputer/backend/src/extensions'; +import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js'; import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js'; import type { Request, Response } from 'express'; import TimeAgo from 'javascript-time-ago'; @@ -155,6 +156,33 @@ export const handleWhoami = async ( // every app actor. Only the verification flag ships. requires_phone_verification: user.requires_phone_verification, requires_card_verification: user.requires_card_verification, + // The SMS-to-card escape hatch: true once this user is out of SMS send + // attempts and may verify a card instead. It has to ship from here + // because /send-confirm-phone can no longer say so — by the time the + // fallback opens, further sends are rejected by that route's own rate + // limit before any handler runs, so a page reload would otherwise lose + // an offer that stays valid for 24 hours. Only for user actors, and it + // costs no KV read unless the account is actually phone-gated. + card_fallback_available: isUser + ? await isCardFallbackEligible( + extension.config, + user, + async (key) => (await stores.kv.get({ key })).res, + { + smsConfigured: () => + Boolean(clients.prelude?.isConfigured()), + probeCardVerification: async () => { + const status = { enabled: null as boolean | null }; + await clients.event?.emitAndWait( + 'puter.card-verification.status', + status, + {}, + ); + return status.enabled; + }, + }, + ) + : false, desktop_bg_url: user.desktop_bg_url, desktop_bg_color: user.desktop_bg_color, desktop_bg_fit: user.desktop_bg_fit, diff --git a/package-lock.json b/package-lock.json index 14cd4b283..92cddf381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18645,7 +18645,7 @@ }, "src/puter-js": { "name": "@heyputer/puter.js", - "version": "2.6.1", + "version": "2.6.2", "license": "Apache-2.0", "dependencies": { "@heyputer/kv.js": "^0.2.1", diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index d50ca0780..b38b8c026 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -277,6 +277,14 @@ export type EventMap = { publishable_key: string | null; [key: string]: unknown; }; + // Side-effect-free "is the card gate usable?" probe. The extension stamps + // `enabled` from its own config and does nothing else — no provider call, + // no user state, nothing billed. It exists so the SMS-to-card fallback can + // default on only where a card gate actually works, which means it gets + // asked often and must stay cheap. + 'puter.card-verification.status': { + enabled: boolean | null; + }; 'puter.card-verification.confirm': { user_id: number; user_uid: string; @@ -610,11 +618,10 @@ export type EventKey = keyof EventMap & string; // Generates a wildcard for every non-final dot-separated prefix of K. export type WildcardPrefixes = K extends `${infer Head}.${infer Tail}` - ? - | `${Head}.*` - | (Tail extends `${string}.${string}` - ? `${Head}.${WildcardPrefixes}` - : never) + ? | `${Head}.*` + | (Tail extends `${string}.${string}` + ? `${Head}.${WildcardPrefixes}` + : never) : never; export type ListenKey = EventKey | WildcardPrefixes; diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 0b6ca493d..dc2453958 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -46,6 +46,7 @@ import { import { PuterServer } from '../../server.js'; import { FULL_API_ACCESS } from '../../services/permission/consts.js'; import { setupTestServer } from '../../testUtil.js'; +import { resetCardVerificationStatusCache } from '../../util/cardFallback.js'; import { FS_READ_LIMIT } from '../fs/limits.js'; // ── Test harness ──────────────────────────────────────────────────── @@ -3659,33 +3660,182 @@ describe('AuthController SMS → card fallback', () => { value: true, }); - it('offers the fallback on send once the attempt threshold is reached', async () => { - const { actor } = await makeUserAndActor({ + it('offers the fallback on send only once SMS attempts are exhausted', async () => { + const { user, actor } = await makeUserAndActor({ requires_phone_verification: 1, }); - // No after_attempts → exercises the default threshold of 2. + // No after_attempts → the default is the send route's whole allowance, + // so the offer appears on the last send that limit allows and not + // before: the card path is for a phone that has run out of tries. await withFallbackConfig({ enabled: true }, async () => { await withPrelude(stubPrelude(), async () => { - const first = makeRes(); + const early = makeRes(); await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), - first, + early, ); - // First attempt is below the threshold — no offer yet. - expect(first.body).toEqual({}); + // One attempt spent, nine still available — no offer. + expect(early.body).toEqual({}); - const second = makeRes(); + // Stop one short of the allowance: still nothing on offer. + await seedAttempts(user.id, 7); + const penultimate = makeRes(); await controller.handleSendConfirmPhone( makeReq({ phone: '+14155550123' }, { actor }), - second, + penultimate, ); - expect(second.body).toEqual({ + expect(penultimate.body).toEqual({}); + + // The tenth send is the last one the route will allow, so this + // is the point the user is out of SMS attempts. + const last = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + last, + ); + expect(last.body).toEqual({ card_fallback_available: true, }); }); }); }); + // The card gate lives in an extension, so the backend asks for its status + // over the event bus. Stub that one client: `null` stands for no extension + // listening at all, which is what a stock build looks like. + const withCardStatus = async ( + enabled: boolean | null, + fn: () => Promise, + ): Promise => { + const ctrl = controller as { clients: { event: unknown } }; + const real = ctrl.clients.event; + ctrl.clients.event = { + emitAndWait: async ( + key: string, + event: Record, + ) => { + if (key === 'puter.card-verification.status') { + if (enabled !== null) event.enabled = enabled; + } + }, + emit: () => undefined, + }; + resetCardVerificationStatusCache(); + try { + await fn(); + } finally { + ctrl.clients.event = real; + resetCardVerificationStatusCache(); + } + }; + + it('defaults on when SMS and card verification are both available', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + // No `phone_verification_card_fallback` at all: the pair it bridges is + // what decides, and here both halves work. + await withFallbackConfig(undefined, async () => { + await withPrelude(stubPrelude(), async () => { + await withCardStatus(true, async () => { + await seedAttempts(user.id, 9); + const res = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + res, + ); + expect(res.body).toEqual({ + card_fallback_available: true, + }); + }); + }); + }); + }); + + it('stays off by default with no card gate behind it', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + // Nothing answers the status probe (stock build) — offering a card path + // here could only strand the user, so the default holds it closed. + await withFallbackConfig(undefined, async () => { + await withPrelude(stubPrelude(), async () => { + await withCardStatus(null, async () => { + await seedAttempts(user.id, 9); + const res = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + res, + ); + expect(res.body).toEqual({}); + }); + }); + }); + }); + + it('stays off by default when the card gate reports itself disabled', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + await withFallbackConfig(undefined, async () => { + await withPrelude(stubPrelude(), async () => { + await withCardStatus(false, async () => { + await seedAttempts(user.id, 9); + const res = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + res, + ); + expect(res.body).toEqual({}); + }); + }); + }); + }); + + it('honours an explicit opt-out even when both gates are available', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + await withFallbackConfig({ enabled: false }, async () => { + await withPrelude(stubPrelude(), async () => { + await withCardStatus(true, async () => { + await seedAttempts(user.id, 9); + const res = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + res, + ); + expect(res.body).toEqual({}); + }); + }); + }); + }); + + it('clamps after_attempts to the send allowance so it stays reachable', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + // A threshold above the send limit could never be crossed on its own + // terms — requests past the limit are rejected in middleware and never + // reach the counter — so it is clamped down to the allowance. + await withFallbackConfig( + { enabled: true, after_attempts: 50 }, + async () => { + await withPrelude(stubPrelude(), async () => { + await seedAttempts(user.id, 9); + const res = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + res, + ); + expect(res.body).toEqual({ + card_fallback_available: true, + }); + }); + }, + ); + }); + it('never offers the fallback on send when disabled', async () => { const { actor } = await makeUserAndActor({ requires_phone_verification: 1, diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 7aa915bc4..3146dc5de 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -54,6 +54,17 @@ import { } from '../../services/auth/OTPUtil.js'; import type { UserRow } from '../../stores/user/UserStore.js'; import { isOwnedEmailConflict } from '../../stores/user/UserStore.js'; +import type { CardFallbackDeps } from '../../util/cardFallback.js'; +import { + CARD_FALLBACK_OPEN_TTL_SECONDS, + SEND_PHONE_RATE_LIMIT, + SEND_PHONE_RATE_WINDOW_MS, + cardFallbackAfterAttempts, + cardFallbackFlagKey, + isCardFallbackEligible, + isCardFallbackEnabled, + phoneAttemptsKey, +} from '../../util/cardFallback.js'; import { sessionCookieFlags } from '../../util/cookieFlags.js'; import { cleanEmail, isBlockedEmail } from '../../util/email.js'; import { generate_identifier } from '../../util/identifier.js'; @@ -77,14 +88,6 @@ const FINGERPRINT_MAX_LENGTH = 128; // crafted request from turning a single grant call into a bulk write. const MAX_PERMISSIONS_PER_REQUEST = 16; const DISPATCH_ID_MAX_LENGTH = 128; -// Default SMS send attempts before the card fallback opens. -const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2; -// /send-confirm-phone route rate limit. Also caps the fallback's -// `after_attempts`: requests past the route limit are rejected in middleware -// and never reach the attempt counter, so a higher threshold could never be -// crossed. -const SEND_PHONE_RATE_LIMIT = 10; -const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000; // -- Post-login route limits ----------------------------------------- // @@ -173,9 +176,6 @@ const SESSION_LIMIT = { window: 60_000, key: 'user', } as const; -// Once the threshold is crossed the fallback stays open this long, so the -// user can finish the card flow without racing the attempt counter's expiry. -const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60; // How long a failed-SMS-send record stays readable by its error_id — long // enough to cover the typical support round-trip. const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60; @@ -1449,9 +1449,10 @@ export class AuthController extends PuterController { // -- SMS-to-card fallback ----------------------------------------- // - // Once a user has made enough SMS send attempts in the rate-limit window - // without getting through, they can verify a card instead to clear the - // phone gate. Off unless config enables it. + // Once a user has used up their SMS send attempts for the window without + // getting through, they can verify a card instead to clear the phone gate. + // On wherever both gates work unless config opts out. The rule itself lives + // in ../../util/cardFallback.ts, because /whoami answers the same question. // // Two KV keys: a short-lived counter tied to the send rate-limit window // triggers the fallback, and a longer-lived "open" flag holds eligibility @@ -1460,30 +1461,11 @@ export class AuthController extends PuterController { // user is mid-way through the card flow. Every KV failure fails closed // (fallback unavailable), never open. - private cardFallbackConfig(): { enabled: boolean; afterAttempts: number } { - const cfg = this.config.phone_verification_card_fallback; - const afterAttempts = Math.min( - typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0 - ? cfg.after_attempts - : DEFAULT_CARD_FALLBACK_ATTEMPTS, - SEND_PHONE_RATE_LIMIT, - ); - return { enabled: Boolean(cfg?.enabled), afterAttempts }; - } - - private phoneAttemptsKey(userId: number): string { - return `phone-verify-attempts:${userId}`; - } - - private cardFallbackFlagKey(userId: number): string { - return `card-fallback-open:${userId}`; - } - // TTL ties the counter to the send rate-limit window, so it resets with it. private async bumpPhoneAttempts(userId: number): Promise { try { const { res } = await this.stores.kv.incr({ - key: this.phoneAttemptsKey(userId), + key: phoneAttemptsKey(userId), pathAndAmountMap: { attempts: 1 }, expireAt: Math.floor(Date.now() / 1000) + @@ -1507,16 +1489,15 @@ export class AuthController extends PuterController { requires_phone_verification?: boolean | number | null; }): Promise { const attempts = await this.bumpPhoneAttempts(user.id); - const { enabled, afterAttempts } = this.cardFallbackConfig(); const open = - enabled && Boolean(user.requires_phone_verification) && - attempts >= afterAttempts; + attempts >= cardFallbackAfterAttempts(this.config) && + (await isCardFallbackEnabled(this.config, this.cardFallbackDeps())); if (open) { try { // Plain set, so each eligible attempt refreshes the window. await this.stores.kv.set({ - key: this.cardFallbackFlagKey(user.id), + key: cardFallbackFlagKey(user.id), value: true, expireAt: Math.floor(Date.now() / 1000) + @@ -1537,17 +1518,33 @@ export class AuthController extends PuterController { id: number; requires_phone_verification?: boolean | number | null; }): Promise { - const { enabled } = this.cardFallbackConfig(); - if (!enabled || !user.requires_phone_verification) return false; - try { - const { res } = await this.stores.kv.get({ - key: this.cardFallbackFlagKey(user.id), - }); - return res === true; - } catch (e) { - console.warn('[card-verification] fallback flag read failed:', e); - return false; - } + return isCardFallbackEligible( + this.config, + user, + async (key) => (await this.stores.kv.get({ key })).res, + this.cardFallbackDeps(), + ); + } + + /** + * The two facts the fallback's default rests on: SMS can only work with a + * provider configured, and the card gate belongs to an extension, so the + * only honest way to ask whether it is on is to ask that extension. Nothing + * is listening on a stock build, which reads as "no card gate". + */ + private cardFallbackDeps(): CardFallbackDeps { + return { + smsConfigured: () => Boolean(this.clients.prelude?.isConfigured()), + probeCardVerification: async () => { + const statusEvent = { enabled: null as boolean | null }; + await this.clients.event?.emitAndWait( + 'puter.card-verification.status', + statusEvent, + {}, + ); + return statusEvent.enabled; + }, + }; } @Post('/send-confirm-phone', { diff --git a/src/backend/types.ts b/src/backend/types.ts index aec8253ee..ba5aacc04 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -784,24 +784,37 @@ interface IConfigOptional { /** * Let a user who keeps getting blocked on SMS phone verification fall back * to credit-card verification, which clears the phone gate (and the card - * gate too, when one is set). Off by default. + * gate too, when one is set). * - * The fallback opens after `after_attempts` SMS _send_ attempts inside the - * send rate-limit window — successful sends count too, so a user who - * receives codes fine can still choose the card path after that many - * requests. This trades the phone signal for a card signal; it does NOT - * guarantee SMS actually failed. Once open, the fallback stays open for 24 - * hours so the user can finish the card flow. Requires a payments extension - * to run the actual card check. + * The fallback opens once the user has made `after_attempts` SMS _send_ + * attempts inside the send rate-limit window, which by default means only + * after they have used up the window's entire send allowance — the card + * option is an escape hatch for a phone that isn't working, not a choice + * offered alongside a working SMS flow. Successful sends count too, so this + * trades the phone signal for a card signal; it does NOT guarantee SMS + * actually failed. Once open, the fallback stays open for 24 hours so the + * user can finish the card flow. Requires a payments extension to run the + * actual card check. */ phone_verification_card_fallback: { - enabled: boolean; + /** + * Tri-state. Set it and that wins, either way — this is the opt-out. + * Omit it and the fallback is on wherever both gates it bridges + * actually work: an SMS provider is configured _and_ an installed + * extension reports card verification enabled. On a build with no card + * gate behind it the fallback stays off, since taking the offer there + * could only strand the user. + */ + enabled?: boolean; /** * SMS send attempts (within the send rate-limit window) before the card - * fallback opens. Defaults to 2 when omitted. Values above the send - * route's rate limit (10/hour) are clamped down to it — requests past - * the route limit never reach the attempt counter, so a higher - * threshold could never be crossed. + * fallback opens. Defaults to the send route's full rate limit + * (10/hour), i.e. the fallback appears only once the user is out of SMS + * attempts. Values above that limit are clamped down to it — requests + * past the route limit never reach the attempt counter, so a higher + * threshold could never be crossed. Lower it (e.g. 2) to reach the card + * path without burning the whole allowance, which is mainly useful for + * QA. */ after_attempts?: number; }; diff --git a/src/backend/util/cardFallback.ts b/src/backend/util/cardFallback.ts new file mode 100644 index 000000000..9b81fcf67 --- /dev/null +++ b/src/backend/util/cardFallback.ts @@ -0,0 +1,181 @@ +/* + * 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 { IConfig } from '../types'; + +/** + * `/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, + ); +} + +/** + * What the fallback needs to know about the rest of the system to decide + * whether it should be on by default. + */ +export interface CardFallbackDeps { + /** Whether SMS verification can work at all — i.e. a provider is set up. */ + smsConfigured: () => boolean; + /** + * Asks whichever extension owns card verification whether the card gate is + * on. Resolves null when nothing is listening (no payments extension), so + * "installed but off" and "not installed" stay distinguishable. + */ + probeCardVerification: () => Promise; +} + +/** + * 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; +let cardStatusCache: { at: number; enabled: boolean | null } | null = null; + +/** Drops the memoized probe answer. For tests, and for a config reload. */ +export function resetCardVerificationStatusCache(): void { + cardStatusCache = null; +} + +async function cardVerificationEnabled( + deps: CardFallbackDeps, +): Promise { + const now = Date.now(); + if (cardStatusCache && now - cardStatusCache.at < CARD_STATUS_TTL_MS) { + return cardStatusCache.enabled; + } + 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 = { at: now, 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 cardVerificationEnabled(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; + } +} diff --git a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js index 72d31990f..1b2da1be8 100644 --- a/src/gui/src/UI/UIWindowPhoneVerificationRequired.js +++ b/src/gui/src/UI/UIWindowPhoneVerificationRequired.js @@ -33,12 +33,18 @@ import { // The 6-digit code UX mirrors UIWindowEmailConfirmationRequired.js. Used as a // hard gate for low-reputation signups, so by default it has no close button. // -// When the server reports `card_fallback_available` on a send (either a -// successful one or a refusal), SMS is not the only way out: the backend has -// opened a card-verification path that clears the phone gate too. This dialog -// surfaces that as an opt-in link rather than leaving the user to retry a send -// that keeps failing. The card dialog can be dismissed straight back here, so -// the choice is reversible either way. +// Once the user is out of SMS send attempts for the window, SMS is not the only +// way out: the backend opens a card-verification path that clears the phone gate +// too, and this dialog surfaces it as an opt-in link rather than leaving the user +// to retry a send that can only be refused. The card dialog can be dismissed +// straight back here, so the choice is reversible either way. +// +// Two things reveal the link, and both are needed. A send response carrying +// `card_fallback_available` covers the attempt that exhausts the allowance, +// which is the send that opens the fallback. `options.card_fallback_available` +// covers every visit after that: further sends are rejected by the route's rate +// limit before the handler runs, so only whoami can still report the offer — +// which matters because it stays valid for 24 hours, well past the send window. // // The number field combines a searchable country-code picker with the national // number. Everything the user types is normalized to E.164 with libphonenumber @@ -512,10 +518,11 @@ function UIWindowPhoneVerificationRequired(options) { // ---------- Card escape hatch ---------- // - // Revealed by a send response carrying `card_fallback_available`. Once - // revealed it stays: the backend keeps the eligibility open for hours, - // and a user who came back to try SMS again shouldn't lose the way out - // they were already offered. + // Revealed by a send response carrying `card_fallback_available`, or + // straight away when the caller already knows the fallback is open + // (whoami reports it — see below). Once revealed it stays: the backend + // keeps the eligibility open for hours, and a user who came back to try + // SMS again shouldn't lose the way out they were already offered. let card_fallback_available = false; let card_fallback_in_progress = false; const revealCardFallback = () => { @@ -524,6 +531,13 @@ function UIWindowPhoneVerificationRequired(options) { $(el_window).find('.phone-card-fallback').prop('hidden', false); }; + // The fallback only opens once the user is out of SMS send attempts, at + // which point every further send is rejected by the route's rate limit + // before the handler runs — so a send response can no longer advertise + // it. On a reload the offer therefore has to come from whoami, which the + // gate's caller passes in. + if (options.card_fallback_available) revealCardFallback(); + // Hand off to the card dialog. This window stays alive behind it (just // hidden) so a user who backs out — or whose card path turns out to be // unavailable server-side — lands back on the live gate instead of on diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 419cda4c9..8db2b6308 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -1695,6 +1695,10 @@ window.initgui = async function (options) { show_close_button: false, stay_on_top: true, has_head: false, + // Already out of SMS attempts (the offer outlives the + // send window, and no send can report it any more). + card_fallback_available: + whoami.card_fallback_available, window_options: { is_draggable: false, }, @@ -1968,6 +1972,10 @@ window.initgui = async function (options) { stay_on_top: true, has_head: false, logout_in_footer: true, + // Already out of SMS attempts (the offer outlives the + // send window, and no send can report it any more). + card_fallback_available: + whoami.card_fallback_available, window_options: { is_draggable: false, cover_page: window.is_embedded, diff --git a/src/puter-js/package-lock.json b/src/puter-js/package-lock.json index f881ba0d6..647f30ee9 100644 --- a/src/puter-js/package-lock.json +++ b/src/puter-js/package-lock.json @@ -1,12 +1,12 @@ { "name": "puter", - "version": "2.6.1", + "version": "2.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "puter", - "version": "2.6.1", + "version": "2.6.2", "license": "Apache-2.0", "dependencies": { "@heyputer/kv.js": "^0.1.92", diff --git a/src/puter-js/package.json b/src/puter-js/package.json index 5e815fb3e..22556ddf3 100644 --- a/src/puter-js/package.json +++ b/src/puter-js/package.json @@ -1,6 +1,6 @@ { "name": "@heyputer/puter.js", - "version": "2.6.1", + "version": "2.6.2", "description": "Puter.js gives you auth, cloud storage, database, AI, and more through a single JavaScript library. It is the go-to backend for AI-generated apps.", "homepage": "https://docs.puter.com", "main": "src/index.js",