mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-06 05:55:42 +00:00
* refactor(util): add BoundedTtlMap for core caches and log throttles
One size-capped, LRU-evicting map with an optional TTL and a once-per-window
shouldEmit helper. Replaces the hand-rolled maps in the DynamoDB throttle and
repair loggers (the repair one was unbounded), the Slack repeat throttle, the
auth probe's reauth log throttle, and the card-verification status memo.
* perf(http): skip route lifecycle work when nothing listens
The per-route lifecycle middleware built a payload, opened an
event.emitAndWait span and hooked finish/close on every request, listeners
or not. Each phase now checks hasListeners first. EventClient's three copies
of the key-prefix walk become one memoized #matchKeys.
* perf(ddb): write batch chunks concurrently and retry the TTL sweep
batchPut/batchDel sent their 25-item chunks one after another; they now run
up to four at a time through runWithConcurrencyLimit, and a failed chunk
stops the ones not yet started. batchGet and the batch write share one
#sendWithUnprocessedRetry instead of two copies of the backoff loop, and the
TTL sweep goes through #batchWrite, so the unprocessed deletes it used to
drop are retried. The AWS and local clients are built by one #bindClient.
* refactor(db): share migrations and tryHardRead in the base client
MySQL and Postgres each carried a copy of the migration-path runner and of
the replica-racing tryHardRead; both now live on AbstractDatabaseClient
(applyMigrationPaths, and tryHardRead gated on hasReadReplica), and SQLite
drops its own tryHardRead override. Removes MySQL reinitPrimary,
reinitReplica, isRetriableError and readWithRetry and the base requireRead,
which had no callers outside tests. SQLite prepared statements and the
Postgres SQL rewrite are memoized per query text in a bounded map.
* refactor(rate-limit): route every entry point through one charge()
The nine rate/peek/acquire entry points each repeated key and limit
resolution, the backend call and the fail-open catch. They now share
charge(), routeKey() and guardSlot(); log lines are unchanged. Driver
concurrency slots release at most once like the other slot handles, and
checkRateLimit takes { failOpen: false } for limits that must hold when the
backend is down.
* refactor(util): add secretsEqual for constant-time secret comparison
One HMAC-then-timingSafeEqual helper, safe for inputs of any length, so
callers stop hand-rolling the length guard. File signature verification
uses it.
* refactor(fs): infer signed-file MIME types with mime-types
mimeFromName looks extensions up in mime-types instead of a 35-entry inline
table, keeping the two types the table reported differently (ico, wav). The
purpose-labelled signing key is derived once per secret instead of on every
signature, where signFile derived it twice.
* refactor(http): build route gates from one gate() helper
Each gate repeated a try/catch -> next(err) wrapper or a .then(next, next)
chain around an assertion. gate(check) does that once: a throw or rejection
goes to next(err), and a synchronous check still calls next synchronously.
The credits, subscription and reputation gate files held nothing but such a
wrapper; their factories move into gates.ts and the files go.
* perf(hosting): build the puter-site hosting config once
createPuterSiteMiddleware rebuilt the normalized hosting-domain config on
every hosted-site request; it is derived from static config, like the domain
lists the middleware already builds at construction.
* fix(db): tryHardRead's replica leg doesn't fail over to the primary
MySQL's read() retries on the primary after a load-shed or transient
replica error, so routing tryHardRead through it read the primary twice
on that path. The replica leg now reads the replica alone.
* fix(ddb): settle in-flight batch chunks before rethrowing
A failed chunk stopped unstarted chunks but rejected while others were
still retrying, so writes could land after the caller had handled the
error and invalidated its cache.
212 lines
8.1 KiB
TypeScript
212 lines
8.1 KiB
TypeScript
/*
|
|
* 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 { EventClient } from '../clients/event/EventClient';
|
|
import type { PreludeClient } from '../clients/prelude/PreludeClient';
|
|
import type { IConfig } from '../types';
|
|
import { BoundedTtlMap } from './boundedTtlMap';
|
|
|
|
/**
|
|
* `/send-confirm-phone` route rate limit: how many verification texts one
|
|
* account can ask for per window. This is also the definition of "out of SMS
|
|
* attempts" — the fallback opens on the last send this allows, because requests
|
|
* past it are rejected in middleware and never reach a handler.
|
|
*/
|
|
export const SEND_PHONE_RATE_LIMIT = 10;
|
|
export const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
|
|
|
|
/**
|
|
* How long the fallback stays open once it opens. Deliberately much longer than
|
|
* the attempt counter's window: the counter exists to detect exhaustion, and
|
|
* once detected the user needs time to finish the card flow (and to come back
|
|
* to it) without racing the counter's expiry.
|
|
*/
|
|
export const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
|
|
|
|
/** Attempt counter, TTL-tied to the send rate-limit window. */
|
|
export const phoneAttemptsKey = (userId: number): string =>
|
|
`phone-verify-attempts:${userId}`;
|
|
|
|
/** Eligibility flag, the only thing the card endpoints read. */
|
|
export const cardFallbackFlagKey = (userId: number): string =>
|
|
`card-fallback-open:${userId}`;
|
|
|
|
/** The user fields the rule reads. */
|
|
export interface PhoneGatedUser {
|
|
id?: number | null;
|
|
requires_phone_verification?: boolean | number | null;
|
|
}
|
|
|
|
/** Reads one system-KV key; resolves whatever was stored (or null/undefined). */
|
|
export type ReadKvFlag = (key: string) => Promise<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,
|
|
);
|
|
}
|
|
|
|
export interface CardFallbackDeps {
|
|
/** Whether an SMS provider is set up. */
|
|
smsConfigured: () => boolean;
|
|
/**
|
|
* Whether the owning extension reports the card gate on; null when none is
|
|
* installed.
|
|
*/
|
|
probeCardVerification: () => Promise<boolean | null>;
|
|
}
|
|
|
|
/** The clients that answer {@link CardFallbackDeps}. */
|
|
export interface CardFallbackClients {
|
|
prelude?: Pick<PreludeClient, 'isConfigured'> | null;
|
|
event?: Pick<EventClient, 'emitAndWait'> | null;
|
|
}
|
|
|
|
/**
|
|
* Deps over the live clients. `clients` is read at call time, not captured, so
|
|
* a client swapped after construction (tests) is what gets asked.
|
|
*/
|
|
export function cardFallbackDepsFrom(
|
|
clients: CardFallbackClients,
|
|
): CardFallbackDeps {
|
|
return {
|
|
smsConfigured: () => Boolean(clients.prelude?.isConfigured()),
|
|
probeCardVerification: async () => {
|
|
const status: { enabled: boolean | null } = { enabled: null };
|
|
await clients.event?.emitAndWait(
|
|
'puter.card-verification.status',
|
|
status,
|
|
{},
|
|
);
|
|
return status.enabled;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Memoized answer to the card-gate probe. The probe is side-effect free, but it
|
|
* is asked on a polled endpoint, so cache it briefly rather than fanning out an
|
|
* event per request. Only the _default_ is cached: an explicit `enabled: false`
|
|
* short-circuits below without ever consulting this, so the operator's kill
|
|
* switch still takes effect immediately.
|
|
*/
|
|
const CARD_STATUS_TTL_MS = 60_000;
|
|
const cardStatusCache = new BoundedTtlMap<'status', boolean | null>({
|
|
maxEntries: 1,
|
|
ttlMs: CARD_STATUS_TTL_MS,
|
|
});
|
|
|
|
/** Drops the memoized probe answer. For tests, and for a config reload. */
|
|
export function resetCardVerificationStatusCache(): void {
|
|
cardStatusCache.clear();
|
|
}
|
|
|
|
/**
|
|
* Whether an installed extension reports the card gate on; null when nothing
|
|
* answers. Memoized per {@link CARD_STATUS_TTL_MS}.
|
|
*/
|
|
export async function isCardVerificationEnabled(
|
|
deps: CardFallbackDeps,
|
|
): Promise<boolean | null> {
|
|
const cached = cardStatusCache.get('status');
|
|
if (cached !== undefined) return cached;
|
|
let enabled: boolean | null = null;
|
|
try {
|
|
enabled = await deps.probeCardVerification();
|
|
} catch (e) {
|
|
// Treat a broken probe as "no card gate": offering a card path that may
|
|
// not work is worse than not offering one.
|
|
console.warn('[card-verification] status probe failed:', e);
|
|
}
|
|
cardStatusCache.set('status', enabled);
|
|
return enabled;
|
|
}
|
|
|
|
/**
|
|
* Whether the SMS-to-card fallback is switched on.
|
|
*
|
|
* `enabled` is a tri-state. Set explicitly, it wins either way — that is the
|
|
* opt-out. Left unset, the fallback follows the pair it bridges: on wherever
|
|
* both halves actually work, off otherwise. Without that conjunction the offer
|
|
* could show up on a deployment with no card gate behind it, where taking it
|
|
* strands the user on a dialog that can only fail.
|
|
*/
|
|
export async function isCardFallbackEnabled(
|
|
config: Pick<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 isCardVerificationEnabled(deps)) === true;
|
|
}
|
|
|
|
/**
|
|
* Whether this user may verify a card in place of the phone gate right now.
|
|
*
|
|
* Reads only the eligibility flag, never the raw attempt counter: the counter
|
|
* expires with the send rate-limit window, so deriving eligibility from it
|
|
* would revoke the offer mid-flow. The cheap disqualifiers (not phone-gated,
|
|
* feature off) come first, so an ordinary `/whoami` never reaches the KV read.
|
|
* Every KV failure fails closed.
|
|
*/
|
|
export async function isCardFallbackEligible(
|
|
config: Pick<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;
|
|
}
|
|
}
|