fix: provide fallback for signup verification (#3631)

This commit is contained in:
Daniel Salazar
2026-08-23 21:49:35 -07:00
committed by GitHub
parent 76cea02513
commit 684d6752f9
12 changed files with 534 additions and 91 deletions
+45
View File
@@ -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<string, unknown>,
).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<string, unknown>,
).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();
+28
View File
@@ -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,
+1 -1
View File
@@ -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",
+12 -5
View File
@@ -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 string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -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<void>,
): Promise<void> => {
const ctrl = controller as { clients: { event: unknown } };
const real = ctrl.clients.event;
ctrl.clients.event = {
emitAndWait: async (
key: string,
event: Record<string, unknown>,
) => {
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,
+46 -49
View File
@@ -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<number> {
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<boolean> {
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<boolean> {
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', {
+26 -13
View File
@@ -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;
};
+181
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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<unknown>;
/**
* 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<IConfig, 'phone_verification_card_fallback'>,
): 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<boolean | null>;
}
/**
* 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<boolean | null> {
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<IConfig, 'phone_verification_card_fallback'>,
deps: CardFallbackDeps,
): Promise<boolean> {
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<IConfig, 'phone_verification_card_fallback'>,
user: PhoneGatedUser,
readFlag: ReadKvFlag,
deps: CardFallbackDeps,
): Promise<boolean> {
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;
}
}
@@ -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
+8
View File
@@ -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,
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",