mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-28 08:56:58 +00:00
fix: provide fallback for signup verification (#3631)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user