Files
puter/src/backend/util/username.ts
T
Daniel Salazar d162549c1d refactor(controllers): one signup, login, confirm and window.user path; userProtected/internalAuth route options (#4189)
* refactor(auth): one signup path for form, temp, OIDC and team accounts

Signup steps (validate hook, phone/card forcing, bonus verdict, provisioning,
signup events) move from AuthController and OIDCService into SignupService.
Username rules, availability and generation move to util/username.ts, and
user-row + group + default-folder creation to provisionUser, which
TeamService and DefaultUserService now share.

Behaviour fixes: a provisioned team seat joins default_user_group like every
other account, and generated temp usernames skip names whose home path is
occupied.

* refactor(auth): one web-session start for password and OIDC sign-in

startWebSession creates the session with the request's IP, user agent, origin
and host and sets the session cookie. OIDC sign-ins used to create their
session without that metadata, so they showed up blank in the session list.

* refactor(auth): one confirmEmail for the code and link confirmation routes

Both routes now re-run the email checks, refuse an address another account
confirmed, strip it from other rows, promote the group and push
user.email_confirmed to the account's sockets (with the originating socket id
on the code route). The link route's full-table UPDATE clearing other
accounts' pending change-email requests is dropped: /change_email/confirm
already refuses an address a confirmed account owns.

* refactor(auth): build window.user once for whoami and sign-in

buildUserDetails and scrubSensitive move from the whoami extension into
util/userDetails.ts. /whoami calls them; sign-in responses now return the same
user-actor payload, scrubbed, so the login copy no longer ships the phone
number and a no-reload login gets oidc_only/otp/directories up front. Taskbar
resolution is best-effort on both.

* refactor(http): userProtected route option replaces AuthController's route walker copy

The user-protected gate is materialized from a userProtected RouteOption
(implies requireUserActor), so AuthController no longer re-implements the
decorator walker to wire it per instance; those routes and the session
routes are plain decorators now. AuthRequired also counts requireVerified
and userProtected.

* refactor(http): internalAuth route option and a shared bodyRecord

EventsController and PeerController each checked x-puter-internal-auth by
hand with different compares; an internalAuth RouteOption now picks the
secret from config and one constant-time gate checks it. ShareController and
EventsController share bodyRecord instead of private copies.

* fix(fs): cap legacy /delete at 500 paths per request

The bulk form resolved, ACL-checked and removed an unbounded paths[] list one
by one. It now refuses more than FS_BATCH_WRITE_MAX_ITEMS (500), the cap the
batch write routes already use, before touching any entry.

* refactor(controllers): user and subdomain writes and reads through their stores

AuthController's 2FA setup/enable/disable and recovery-code writes go through
UserStore.update instead of raw UPDATEs plus a manual invalidate, and
/readdir-subdomains pages SubdomainStore.listByUserId instead of an unbounded
SELECT; the response shape is unchanged.

* refactor(auth): note SignupService's call-time peers

* fix(auth): type userDetails' taskbar deps independently of TaskbarDeps.clients
2026-10-11 22:00:53 -07:00

118 lines
4.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/>.
*/
import { HttpError } from '../core/http/HttpError.js';
import type { FSEntryStore } from '../stores/fs/FSEntryStore.js';
import type { UserStore } from '../stores/user/UserStore.js';
import { isUniqueViolation } from './dbError.js';
import { generate_identifier } from './identifier.js';
import { isReservedUsername } from './reservedUsernames.js';
export const USERNAME_REGEX = /^\w{1,}$/;
export const USERNAME_MAX_LENGTH = 45;
const GENERATE_ATTEMPTS = 20;
interface UsernameStores {
user: Pick<UserStore, 'getByUsername'>;
fsEntry: Pick<FSEntryStore, 'findHomePathConflict'>;
}
export type UsernameRejection = 'format' | 'length' | 'reserved';
/** Which account-name rule `username` breaks, or null when it breaks none. */
export const usernameRejection = (
username: string,
): UsernameRejection | null => {
if (!USERNAME_REGEX.test(username)) return 'format';
if (username.length > USERNAME_MAX_LENGTH) return 'length';
if (isReservedUsername(username)) return 'reserved';
return null;
};
/** Throws the 400 signup answers for a name that breaks a rule. */
export const assertValidUsername = (username: string): void => {
const rejection = usernameRejection(username);
if (rejection === 'format') {
throw new HttpError(
400,
'Username can only contain letters, numbers and underscore (_).',
{ legacyCode: 'bad_request' },
);
}
if (rejection === 'length') {
throw new HttpError(
400,
`Username cannot be longer than ${USERNAME_MAX_LENGTH} characters.`,
{ legacyCode: 'bad_request' },
);
}
if (rejection === 'reserved') {
throw new HttpError(400, 'This username is not available.', {
legacyCode: 'username_already_in_use',
});
}
};
/**
* What already holds `username`: another account, or rows at or under its home
* path (which would give a new account a second root there). `ownId` is the
* account claiming it, excluded from both checks.
*/
export const usernameConflict = async (
stores: UsernameStores,
username: string,
ownId?: number,
): Promise<'account' | 'home' | null> => {
const holder = await stores.user.getByUsername(username);
if (holder && holder.id !== ownId) return 'account';
const homeTaken = await stores.fsEntry.findHomePathConflict(
username,
ownId,
{ includeDescendants: true },
);
return homeTaken ? 'home' : null;
};
/** A random free username, or null after a bounded number of misses. */
export const generateUsername = async (
stores: UsernameStores,
): Promise<string | null> => {
for (let i = 0; i < GENERATE_ATTEMPTS; i++) {
const candidate = generate_identifier();
if (!(await usernameConflict(stores, candidate))) return candidate;
}
return null;
};
/**
* Whether a failed write to user `ownId` (none for an insert) lost `username`
* to another account. The primary is read because the winner may not have
* replicated yet.
*/
export const isUsernameTaken = async (
userStore: Pick<UserStore, 'getByUsername'>,
err: unknown,
username: string,
ownId?: number,
): Promise<boolean> => {
if (!isUniqueViolation(err)) return false;
const holder = await userStore.getByUsername(username, { force: true });
return Boolean(holder && holder.id !== ownId);
};