Files
puter/src/backend/controllers/auth/AuthController.js
T
2026-04-30 16:45:54 -07:00

2395 lines
93 KiB
JavaScript

import bcrypt from 'bcrypt';
import crypto from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import validator from 'validator';
import { HttpError } from '../../core/http/HttpError.js';
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
import { createUserProtectedGate } from '../../core/http/middleware/userProtected.js';
import {
createRecoveryCode,
hashRecoveryCode,
createSecret as otpCreateSecret,
verify as verifyOtp,
} from '../../services/auth/OTPUtil.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { generate_identifier } from '../../util/identifier.js';
import { getTaskbarItems } from '../../util/taskbarItems.js';
import {
generateDefaultFsentries,
promoteToVerifiedGroup,
} from '../../util/userProvisioning.js';
import { PuterController } from '../types.js';
const USERNAME_REGEX = /^\w{1,}$/;
const USERNAME_MAX_LENGTH = 45;
const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
'root',
'system',
'puter',
'www',
'api',
'support',
'help',
'info',
'contact',
'mail',
'email',
'null',
'undefined',
'test',
'guest',
'anonymous',
'user',
'users',
]);
/**
* Auth controller — login/logout, permission grants/revokes, session
* management, OTP, and permission checks.
*
* Uses imperative route registration (no decorators) so it stays JS.
*/
export class AuthController extends PuterController {
constructor(config, clients, stores, services) {
super(config, clients, stores, services);
}
registerRoutes(
/** @type {import('../../core/http/PuterRouter.js').PuterRouter} */ router,
) {
// ── Login ───────────────────────────────────────────────────
router.post(
'/login',
{
subdomain: ['api', ''],
captcha: true,
rateLimit: { scope: 'login', limit: 10, window: 15 * 60_000 },
},
async (req, res) => {
const { username, email, password } = req.body;
if (!username && !email) {
throw new HttpError(400, 'Username or email is required.');
}
if (!password || typeof password !== 'string') {
throw new HttpError(400, 'Password is required.');
}
if (password.length < (this.config.min_pass_length || 6)) {
throw new HttpError(400, 'Invalid password.');
}
// Look up user
let user;
if (username) {
if (typeof username !== 'string')
throw new HttpError(400, 'username must be a string.');
user = await this.stores.user.getByUsername(username);
} else {
user = await this.stores.user.getByEmail(email);
}
if (!user) {
throw new HttpError(
400,
username ? 'Username not found.' : 'Email not found.',
);
}
if (
user.username === 'system' &&
!this.config.allow_system_login
) {
throw new HttpError(
400,
username ? 'Username not found.' : 'Email not found.',
);
}
if (user.suspended) {
throw new HttpError(401, 'This account is suspended.');
}
if (user.password === null) {
throw new HttpError(400, 'Incorrect password.');
}
// Verify password
const passwordMatch = await bcrypt.compare(
password,
user.password,
);
if (!passwordMatch) {
throw new HttpError(400, 'Incorrect password.');
}
// OTP branching — if 2FA enabled, return a short-lived OTP JWT
if (user.otp_enabled) {
const otp_jwt_token = this.services.token.sign(
'otp',
{
user_uid: user.uuid,
purpose: 'otp-login',
},
{ expiresIn: '5m' },
);
return res.status(202).json({
proceed: true,
next_step: 'otp',
otp_jwt_token,
});
}
return this.#completeLogin(req, res, user);
},
);
// ── Login: OTP verification ─────────────────────────────────
router.post(
'/login/otp',
{
subdomain: ['api', ''],
captcha: true,
rateLimit: {
scope: 'login-otp',
limit: 15,
window: 30 * 60_000,
},
},
async (req, res) => {
const { token, code } = req.body;
if (!token) throw new HttpError(400, 'token is required.');
if (!code) throw new HttpError(400, 'code is required.');
let decoded;
try {
decoded = this.services.token.verify('otp', token);
} catch {
throw new HttpError(400, 'Invalid token.');
}
if (!decoded.user_uid || decoded.purpose !== 'otp-login') {
throw new HttpError(400, 'Invalid token.');
}
const user = await this.stores.user.getByUuid(decoded.user_uid);
if (!user) throw new HttpError(400, 'User not found.');
if (user.suspended) {
throw new HttpError(401, 'This account is suspended.');
}
if (!verifyOtp(user.username, user.otp_secret, code)) {
return res.json({ proceed: false });
}
return this.#completeLogin(req, res, user);
},
);
// ── Login: recovery code ────────────────────────────────────
router.post(
'/login/recovery-code',
{
subdomain: ['api', ''],
captcha: true,
rateLimit: {
scope: 'login-recovery',
limit: 10,
window: 60 * 60_000,
},
},
async (req, res) => {
const { token, code } = req.body;
if (!token) throw new HttpError(400, 'token is required.');
if (!code) throw new HttpError(400, 'code is required.');
let decoded;
try {
decoded = this.services.token.verify('otp', token);
} catch {
throw new HttpError(400, 'Invalid token.');
}
if (!decoded.user_uid || decoded.purpose !== 'otp-login') {
throw new HttpError(400, 'Invalid token.');
}
const user = await this.stores.user.getByUuid(decoded.user_uid);
if (!user) throw new HttpError(400, 'User not found.');
if (user.suspended) {
throw new HttpError(401, 'This account is suspended.');
}
const hashed = hashRecoveryCode(code);
const codes = (user.otp_recovery_codes || '')
.split(',')
.filter(Boolean);
const idx = codes.indexOf(hashed);
if (idx === -1) {
return res.json({ proceed: false });
}
// Consume the recovery code
codes.splice(idx, 1);
await this.clients.db.write(
'UPDATE `user` SET `otp_recovery_codes` = ? WHERE `uuid` = ?',
[codes.join(','), user.uuid],
);
await this.stores.user.invalidateById(user.id);
return this.#completeLogin(req, res, user);
},
);
// ── Signup ──────────────────────────────────────────────────
router.post(
'/signup',
{
subdomain: ['api', ''],
captcha: true,
rateLimit: { scope: 'signup', limit: 10, window: 15 * 60_000 },
},
async (req, res) => {
const body = req.body ?? {};
const is_temp = Boolean(body.is_temp);
// Bot honeypot — only applies to non-temp signups
if (
!is_temp &&
body.p102xyzname !== '' &&
body.p102xyzname !== undefined
) {
return res.json({});
}
// Fill in temp user defaults
if (is_temp) {
body.username ??= await this.#generateRandomUsername();
body.email ??= `${body.username}@gmail.com`;
body.password ??= uuidv4();
}
// Validation
if (!body.username)
throw new HttpError(400, 'Username is required');
if (typeof body.username !== 'string')
throw new HttpError(400, 'username must be a string.');
if (!USERNAME_REGEX.test(body.username)) {
throw new HttpError(
400,
'Username can only contain letters, numbers and underscore (_).',
);
}
if (body.username.length > USERNAME_MAX_LENGTH) {
throw new HttpError(
400,
`Username cannot be longer than ${USERNAME_MAX_LENGTH} characters.`,
);
}
if (RESERVED_USERNAMES.has(body.username.toLowerCase())) {
throw new HttpError(400, 'This username is not available.');
}
if (!is_temp) {
if (!body.email)
throw new HttpError(400, 'Email is required');
if (typeof body.email !== 'string')
throw new HttpError(400, 'email must be a string.');
if (!validator.isEmail(body.email))
throw new HttpError(
400,
'Please enter a valid email address.',
);
await this.#validateEmail(body.email);
if (!body.password)
throw new HttpError(400, 'Password is required');
if (typeof body.password !== 'string')
throw new HttpError(400, 'password must be a string.');
const minLen = this.config.min_pass_length || 6;
if (body.password.length < minLen) {
throw new HttpError(
400,
`Password must be at least ${minLen} characters long.`,
);
}
}
// Duplicate username check
if (await this.stores.user.getByUsername(body.username)) {
throw new HttpError(
400,
'This username already exists in our database. Please use another one.',
);
}
// Duplicate confirmed-email check. A confirmed account (any
// credential type — password OR OIDC) on this email → reject.
//
// A pseudo-user is an UNCONFIRMED placeholder row: email
// present, password null, email_confirmed = 0. Those rows
// (e.g. admin-created pre-provisioning) are NOT a block —
// signup claims them: the INSERT becomes an UPDATE on the
// pseudo row.
//
// OIDC-created accounts have password null but email_confirmed
// = 1, so they fall in the reject branch — signup can't hijack
// someone's OIDC account by knowing their email. To add a
// password to an OIDC account, the owner logs in via OIDC and
// uses the authenticated change-password flow.
//
// Match on both raw `email` and canonical `clean_email` so
// gmail-style aliases (`foo.bar+tag@gmail.com` vs
// `foobar@gmail.com`) collapse to the same account.
let pseudo_user = null;
if (!is_temp) {
const canonical = cleanEmail(body.email);
const existing =
(await this.stores.user.getByEmail(body.email)) ??
(await this.stores.user.getByCleanEmail(canonical));
if (existing) {
// Confirmed account (regardless of credential type) → reject.
if (
existing.email_confirmed ||
existing.password !== null
) {
throw new HttpError(
400,
'This email already exists in our database. Please use another one.',
);
}
// Password-null AND unconfirmed → treat as pseudo.
pseudo_user = existing;
}
}
// Extension-level validation gate. Abuse-prevention extensions
// inspect the incoming signup and can:
// - block it outright via `event.allow = false`
// - force email confirmation via `event.requires_email_confirmation = true`
// - skip temp-user creation via `event.no_temp_user = true`
// Listeners run sequentially so multi-signal checks (rate limit +
// IP reputation + domain reputation) can short-circuit cleanly.
const validateEvent = {
req,
data: body,
ip:
req.headers?.['x-forwarded-for'] ||
req.connection?.remoteAddress ||
req.ip ||
req.socket?.remoteAddress ||
null,
email: body.email,
allow: true,
no_temp_user: false,
requires_email_confirmation: false,
message: null,
};
try {
await this.clients.event?.emitAndWait(
'puter.signup.validate',
validateEvent,
{},
);
} catch (e) {
console.warn('[signup] validate hook failed:', e);
}
if (!validateEvent.allow) {
throw new HttpError(
403,
validateEvent.message ?? 'Signup blocked',
);
}
if (is_temp && validateEvent.no_temp_user) {
throw new HttpError(
403,
validateEvent.message ??
'Temporary accounts are disabled',
);
}
const force_email_confirmation = Boolean(
validateEvent.requires_email_confirmation,
);
// Prepare shared fields
const user_uuid = uuidv4();
const email_confirm_code = String(
crypto.randomInt(100000, 1000000),
);
const email_confirm_token = uuidv4();
const password_hash = is_temp
? null
: await bcrypt.hash(body.password, 8);
const signupSqlTs = new Date()
.toISOString()
.slice(0, 19)
.replace('T', ' ');
let user;
if (pseudo_user) {
// ── Pseudo-user claim (convert the placeholder row) ──
await this.stores.user.update(pseudo_user.id, {
username: body.username,
password: password_hash,
uuid: user_uuid,
email_confirm_code,
email_confirm_token,
email_confirmed: 0,
// Pseudo claims always require email confirmation — the
// validate hook can only tighten, not loosen, so `1`
// stays hardcoded here.
requires_email_confirmation: 1,
last_activity_ts: signupSqlTs,
});
// Move from temp group to regular user group
if (this.config.default_temp_group) {
try {
await this.stores.group.removeUsers(
this.config.default_temp_group,
[body.username],
);
} catch {
// Best-effort — missing membership shouldn't block signup
}
}
if (this.config.default_user_group) {
try {
await this.stores.group.addUsers(
this.config.default_user_group,
[body.username],
);
} catch (e) {
console.warn(
'[signup] group assignment failed:',
e,
);
}
}
user = await this.stores.user.getById(pseudo_user.id, {
force: true,
});
} else {
// ── New user ────────────────────────────────────────
const clientIp =
req.ip || req.socket?.remoteAddress || null;
const proxyIpChain = req.headers['x-forwarded-for'];
user = await this.stores.user.create({
username: body.username,
uuid: user_uuid,
password: password_hash,
email: is_temp ? null : body.email,
clean_email: is_temp ? null : cleanEmail(body.email),
free_storage: this.config.storage_capacity ?? null,
requires_email_confirmation:
!is_temp || force_email_confirmation,
email_confirm_code,
email_confirm_token,
audit_metadata: {
ip: clientIp,
ip_fwd: proxyIpChain,
user_agent: req.headers?.['user-agent'],
origin: req.headers?.origin,
},
signup_ip: clientIp,
signup_ip_forwarded: proxyIpChain,
signup_user_agent: req.headers?.['user-agent'] ?? null,
signup_origin: req.headers?.origin ?? null,
signup_server: this.config.serverId,
referrer: req.body.referrer ?? null,
last_activity_ts: signupSqlTs,
});
// Add to default group
const defaultGroup = is_temp
? this.config.default_temp_group
: this.config.default_user_group;
if (defaultGroup) {
try {
await this.stores.group.addUsers(defaultGroup, [
user.username,
]);
} catch (e) {
console.warn(
'[signup] group assignment failed:',
e,
);
}
}
}
// ── Provision FS home + default folders ─────────────────
// Idempotent — skips if `user.trash_uuid` is already set (pseudo
// users who went through a prior signup won't double-create).
try {
await generateDefaultFsentries(
this.clients.db,
this.stores.user,
user,
);
} catch (e) {
console.warn(
'[signup] generateDefaultFsentries failed:',
e,
);
}
// ── Send email confirmation ─────────────────────────────
if (
!is_temp &&
user.requires_email_confirmation &&
this.clients.email
) {
const sendCode = body.send_confirmation_code ?? true;
try {
if (sendCode) {
await this.clients.email.send(
user.email,
'email_verification_code',
{
code: email_confirm_code,
},
);
} else {
const link = `${this.config.origin ?? ''}/confirm-email-by-token?token=${email_confirm_token}&user_uuid=${user.uuid}`;
await this.clients.email.send(
user.email,
'email_verification_link',
{ link },
);
}
} catch (e) {
console.warn('[signup] email send failed:', e);
}
}
// Fire signup events (best-effort). `user.save_account` is fired
// for every non-temp signup (fresh or pseudo-claim) — downstream
// consumers (mailchimp sync, welcome email, etc.) key off it.
try {
this.clients.event?.emit(
'puter.signup.success',
{
user_id: user.id,
user_uuid: user.uuid,
email: user.email,
username: user.username,
ip: req.ip || req.socket?.remoteAddress,
},
{},
);
} catch {
// ignore — event emission shouldn't block signup
}
if (!is_temp) {
try {
this.clients.event?.emit(
'user.save_account',
{ user_id: user.id },
{},
);
} catch {
// ignore
}
}
return this.#completeLogin(req, res, user);
},
);
// ── Logout ──────────────────────────────────────────────────
router.post(
'/logout',
{ subdomain: ['api', ''], requireAuth: true, antiCsrf: true },
async (req, res) => {
// Clear the session cookie
res.clearCookie(this.config.cookie_name);
// Remove the session (fire-and-forget)
if (req.token) {
this.services.auth
.removeSessionByToken(req.token)
.catch(() => {});
}
// Delete temp users (no password + no email). Full cascade —
// same path as /user-protected/delete-own-user — so we don't
// orphan fsentries/sessions/permissions.
if (req.actor?.user && !req.actor.user.email) {
const user = await this.stores.user.getByUuid(
req.actor.user.uuid,
);
if (user && user.password === null && user.email === null) {
this.#cascadeDeleteUser(user.id).catch((e) => {
console.warn(
'[logout] temp-user cleanup failed:',
e,
);
});
}
}
res.send('logged out');
},
);
// ── Email confirmation ──────────────────────────────────────
router.post(
'/send-confirm-email',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'send-confirm-email',
limit: 10,
window: 60 * 60_000,
key: 'user',
},
},
async (req, res) => {
const user = await this.stores.user.getById(req.actor.user.id, {
force: true,
});
if (!user) throw new HttpError(404, 'User not found.');
if (user.suspended)
throw new HttpError(403, 'Account suspended.');
if (!user.email) throw new HttpError(400, 'No email on file.');
const code = String(crypto.randomInt(100000, 1000000));
await this.stores.user.update(user.id, {
email_confirm_code: code,
});
if (this.clients.email) {
try {
await this.clients.email.send(
user.email,
'email_verification_code',
{ code },
);
} catch (e) {
console.warn('[send-confirm-email] send failed:', e);
}
}
res.json({});
},
);
router.post(
'/confirm-email',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'confirm-email',
limit: 10,
window: 10 * 60_000,
key: 'user',
},
},
async (req, res) => {
const { code, original_client_socket_id } = req.body ?? {};
if (!code) throw new HttpError(400, 'Missing `code`.');
const user = await this.stores.user.getById(req.actor.user.id, {
force: true,
});
if (!user) throw new HttpError(404, 'User not found.');
if (user.email_confirmed) {
return res.json({
email_confirmed: true,
original_client_socket_id,
});
}
if (String(user.email_confirm_code) !== String(code)) {
return res.json({
email_confirmed: false,
original_client_socket_id,
});
}
// Re-validate the email at confirmation time — the address may
// have been added to the blocklist (or flagged by an extension)
// after signup but before confirmation.
await this.#validateEmail(user.email);
await this.stores.user.update(user.id, {
email_confirmed: 1,
requires_email_confirmation: 0,
email_confirm_code: null,
email_confirm_token: null,
});
await promoteToVerifiedGroup(
this.stores.group,
this.config,
user,
);
try {
this.clients.event?.emit(
'user.email-confirmed',
{
user_id: user.id,
user_uid: user.uuid,
email: user.email,
},
{},
);
} catch {
// ignore — event is a side-channel signal, not load-bearing
}
res.json({ email_confirmed: true, original_client_socket_id });
},
);
// ── Password recovery ───────────────────────────────────────
router.post(
'/send-pass-recovery-email',
{
subdomain: ['api', ''],
rateLimit: {
scope: 'send-pass-recovery-email',
limit: 10,
window: 60 * 60_000,
},
},
async (req, res) => {
const { username, email } = req.body ?? {};
if (!username && !email) {
throw new HttpError(400, 'username or email is required.');
}
const genericMessage =
'If that account exists, a password recovery email was sent.';
let user;
if (username) {
user = await this.stores.user.getByUsername(username);
} else {
if (!validator.isEmail(email))
throw new HttpError(400, 'Invalid email.');
user = await this.stores.user.getByEmail(email);
}
if (!user || user.suspended || !user.email) {
return res.json({ message: genericMessage });
}
const pass_recovery_token = uuidv4();
await this.stores.user.update(user.id, { pass_recovery_token });
const jwt = this.services.token.sign(
'otp',
{
token: pass_recovery_token,
user_uid: user.uuid,
email: user.email,
purpose: 'pass-recovery',
},
{ expiresIn: '1h' },
);
const origin = this.config.origin ?? '';
const link = `${origin}/action/set-new-password?token=${encodeURIComponent(jwt)}`;
if (this.clients.email) {
try {
await this.clients.email.send(
user.email,
'email_password_recovery',
{ link },
);
} catch (e) {
console.warn(
'[send-pass-recovery-email] send failed:',
e,
);
}
}
res.json({ message: genericMessage });
},
);
router.post(
'/verify-pass-recovery-token',
{
subdomain: ['api', ''],
rateLimit: {
scope: 'verify-pass-recovery-token',
limit: 10,
window: 15 * 60_000,
},
},
async (req, res) => {
const { token } = req.body ?? {};
if (!token) throw new HttpError(400, 'Missing `token`.');
let decoded;
try {
decoded = this.services.token.verify('otp', token);
} catch {
throw new HttpError(400, 'Invalid or expired token.');
}
if (decoded.purpose !== 'pass-recovery') {
throw new HttpError(400, 'Invalid or expired token.');
}
const user = await this.stores.user.getByUuid(decoded.user_uid);
if (!user || user.email !== decoded.email) {
throw new HttpError(400, 'Token is no longer valid.');
}
if (user.suspended) {
throw new HttpError(401, 'This account is suspended.');
}
const exp = decoded.exp;
const time_remaining = exp
? Math.max(0, exp - Math.floor(Date.now() / 1000))
: 0;
res.json({ time_remaining });
},
);
router.post(
'/set-pass-using-token',
{
subdomain: ['api', ''],
rateLimit: {
scope: 'set-pass-using-token',
limit: 10,
window: 60 * 60_000,
},
},
async (req, res) => {
const { token, password } = req.body ?? {};
if (!token || !password) {
throw new HttpError(400, 'Missing `token` or `password`.');
}
const minLen = this.config.min_pass_length || 6;
if (password.length < minLen) {
throw new HttpError(
400,
`Password must be at least ${minLen} characters long.`,
);
}
let decoded;
try {
decoded = this.services.token.verify('otp', token);
} catch {
throw new HttpError(400, 'Invalid or expired token.');
}
if (decoded.purpose !== 'pass-recovery') {
throw new HttpError(400, 'Invalid or expired token.');
}
const user = await this.stores.user.getByUuid(decoded.user_uid);
if (!user || user.email !== decoded.email) {
throw new HttpError(400, 'Token is no longer valid.');
}
if (user.suspended) {
throw new HttpError(401, 'This account is suspended.');
}
// Atomic check: only update if the recovery token still matches
const password_hash = await bcrypt.hash(password, 8);
const result = await this.clients.db.write(
'UPDATE `user` SET `password` = ?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL WHERE `id` = ? AND `pass_recovery_token` = ?',
[password_hash, user.id, decoded.token],
);
const affected = result?.affectedRows ?? result?.changes ?? 0;
if (affected === 0) {
throw new HttpError(400, 'Token has already been used.');
}
await this.stores.user.invalidateById(user.id);
res.send('Password successfully updated.');
},
);
// The `/user-protected/*` gate (session-cookie + password/OIDC
// revalidation) is applied below. Identity is already proven by
// the gate, so these handlers receive a pre-refreshed user row on
// `req.userProtected.user` and don't re-check the old password.
const userProtectedDeps = {
config: this.config,
userStore: this.stores.user,
oidcService: this.services.oidc,
tokenService: this.services.token,
};
router.post(
'/user-protected/change-password',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'passwd',
limit: 10,
window: 60 * 60_000,
key: 'user',
},
middleware: createUserProtectedGate(userProtectedDeps),
},
async (req, res) => {
const { new_pass } = req.body ?? {};
if (!new_pass) throw new HttpError(400, 'Missing `new_pass`.');
const minLen = this.config.min_pass_length || 6;
if (new_pass.length < minLen) {
throw new HttpError(
400,
`Password must be at least ${minLen} characters long.`,
);
}
const user = req.userProtected.user;
const password_hash = await bcrypt.hash(new_pass, 8);
await this.stores.user.update(user.id, {
password: password_hash,
pass_recovery_token: null,
change_email_confirm_token: null,
});
if (this.clients.email && user.email) {
try {
await this.clients.email.send(
user.email,
'password_change_notification',
{
username: user.username,
},
);
} catch (e) {
console.warn(
'[change-password] notification send failed:',
e,
);
}
}
res.send('Password successfully updated.');
},
);
// ── Change username ─────────────────────────────────────────
router.post(
'/user-protected/change-username',
{
subdomain: ['api', ''],
requireUserActor: true,
requireVerified: true,
rateLimit: {
scope: 'change-username',
limit: 2,
window: 30 * 24 * 60 * 60_000,
key: 'user',
},
middleware: createUserProtectedGate(userProtectedDeps),
},
async (req, res) => {
const { new_username } = req.body ?? {};
if (!new_username || typeof new_username !== 'string') {
throw new HttpError(400, '`new_username` is required');
}
if (!USERNAME_REGEX.test(new_username)) {
throw new HttpError(
400,
'Username can only contain letters, numbers and underscore (_).',
);
}
if (new_username.length > USERNAME_MAX_LENGTH) {
throw new HttpError(
400,
`Username cannot be longer than ${USERNAME_MAX_LENGTH} characters.`,
);
}
if (RESERVED_USERNAMES.has(new_username.toLowerCase())) {
throw new HttpError(400, 'This username is not available.');
}
if (await this.stores.user.getByUsername(new_username)) {
throw new HttpError(400, 'This username is already taken.');
}
await this.stores.user.update(req.actor.user.id, {
username: new_username,
});
// Rename the user's FS home from `/<old>` to `/<new>` and
// cascade the prefix to all descendants. Without this, any
// path-based lookup (stat/readdir/write) would 404 after
// rename because the fsentries still reference `/<old>`.
try {
await this.stores.fsEntry.renameUserHome(
req.actor.user.id,
new_username,
);
} catch (e) {
console.warn('[change-username] fs home rename failed:', e);
}
try {
this.clients.event?.emit(
'user.username-changed',
{
user_id: req.actor.user.id,
old_username: req.actor.user.username,
new_username,
},
{},
);
} catch {
// event emission best-effort
}
res.json({ username: new_username });
},
);
// ── Change email ────────────────────────────────────────────
router.post(
'/user-protected/change-email',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'change-email-start',
limit: 10,
window: 60 * 60_000,
key: 'user',
},
middleware: createUserProtectedGate(userProtectedDeps),
},
async (req, res) => {
const { new_email } = req.body ?? {};
if (!new_email || typeof new_email !== 'string') {
throw new HttpError(400, '`new_email` is required');
}
if (!validator.isEmail(new_email)) {
throw new HttpError(
400,
'Please enter a valid email address.',
);
}
await this.#validateEmail(new_email);
// Block if any confirmed account (password or OIDC) already
// owns that email. Match raw + canonical to collapse gmail
// aliases.
const canonical = cleanEmail(new_email);
const existing =
(await this.stores.user.getByEmail(new_email)) ??
(await this.stores.user.getByCleanEmail(canonical));
if (
existing &&
(existing.email_confirmed || existing.password !== null)
) {
throw new HttpError(400, 'This email is already in use.');
}
const confirm_token = uuidv4();
await this.stores.user.update(req.actor.user.id, {
unconfirmed_change_email: new_email,
change_email_confirm_token: confirm_token,
});
const linkJwt = this.services.token.sign(
'otp',
{
token: confirm_token,
user_id: req.actor.user.id,
purpose: 'change-email',
},
{ expiresIn: '1h' },
);
if (this.clients.email) {
const origin = this.config.origin ?? '';
const link = `${origin}/change_email/confirm?token=${encodeURIComponent(linkJwt)}`;
try {
await this.clients.email.send(
new_email,
'email_verification_link',
{ link },
);
} catch (e) {
console.warn(
'[change-email] new-address email failed:',
e,
);
}
// Notify the old address too
const user = await this.stores.user.getById(
req.actor.user.id,
{ force: true },
);
if (user?.email) {
try {
await this.clients.email.sendRaw({
to: user.email,
subject:
'Your Puter email change was requested',
text: `A change to ${new_email} was requested on your account. If this wasn't you, please contact support.`,
});
} catch (e) {
console.warn(
'[change-email] old-address notice failed:',
e,
);
}
}
}
res.json({});
},
);
router.get(
'/change_email/confirm',
{
subdomain: ['api', ''],
rateLimit: {
scope: 'change-email-confirm',
limit: 10,
window: 60 * 60_000,
},
},
async (req, res) => {
const jwtToken = req.query?.token;
if (!jwtToken || typeof jwtToken !== 'string') {
throw new HttpError(400, 'Missing `token`');
}
let decoded;
try {
decoded = this.services.token.verify('otp', jwtToken);
} catch {
throw new HttpError(400, 'Invalid or expired token.');
}
if (decoded.purpose !== 'change-email' || !decoded.token) {
throw new HttpError(400, 'Invalid or expired token.');
}
const rows = await this.clients.db.read(
'SELECT * FROM `user` WHERE `change_email_confirm_token` = ? LIMIT 1',
[decoded.token],
);
const user = rows[0];
if (!user || !user.unconfirmed_change_email) {
throw new HttpError(400, 'Invalid or expired token.');
}
const newEmail = user.unconfirmed_change_email;
// Re-check nobody claimed the new email meanwhile. Match raw +
// canonical; block if any real account (confirmed OR
// password-holding) already owns it.
const canonical = cleanEmail(newEmail);
const owner =
(await this.stores.user.getByEmail(newEmail)) ??
(await this.stores.user.getByCleanEmail(canonical));
if (
owner &&
owner.id !== user.id &&
(owner.email_confirmed || owner.password !== null)
) {
throw new HttpError(400, 'This email is already in use.');
}
await this.stores.user.update(user.id, {
email: newEmail,
clean_email: cleanEmail(newEmail),
unconfirmed_change_email: null,
change_email_confirm_token: null,
pass_recovery_token: null,
email_confirmed: 1,
requires_email_confirmation: 0,
});
try {
this.clients.event?.emit(
'user.email-changed',
{
user_id: user.id,
new_email: newEmail,
},
{},
);
} catch {
// best-effort
}
res.send(
'Email changed successfully. You may close this window.',
);
},
);
// ── Save account (convert temp user to permanent) ────────────
router.post(
'/save_account',
{
subdomain: ['api', ''],
requireUserActor: true,
captcha: true,
rateLimit: {
scope: 'save-account',
limit: 10,
window: 60 * 60_000,
key: 'user',
},
},
async (req, res) => {
const { username, email, password } = req.body ?? {};
const user = await this.stores.user.getById(req.actor.user.id, {
force: true,
});
if (!user) throw new HttpError(404, 'User not found');
if (user.password !== null || user.email !== null) {
throw new HttpError(
400,
'This is not a temporary account.',
);
}
// Validation
if (
!username ||
typeof username !== 'string' ||
!USERNAME_REGEX.test(username)
) {
throw new HttpError(400, 'Invalid username.');
}
if (username.length > USERNAME_MAX_LENGTH) {
throw new HttpError(
400,
`Username cannot be longer than ${USERNAME_MAX_LENGTH} characters.`,
);
}
if (RESERVED_USERNAMES.has(username.toLowerCase())) {
throw new HttpError(400, 'This username is not available.');
}
if (!email || !validator.isEmail(email)) {
throw new HttpError(
400,
'Please enter a valid email address.',
);
}
await this.#validateEmail(email);
if (!password || typeof password !== 'string') {
throw new HttpError(400, 'Password is required.');
}
const minLen = this.config.min_pass_length || 6;
if (password.length < minLen) {
throw new HttpError(
400,
`Password must be at least ${minLen} characters long.`,
);
}
// Duplicate checks
const existingUsername =
await this.stores.user.getByUsername(username);
if (existingUsername && existingUsername.id !== user.id) {
throw new HttpError(400, 'This username is already taken.');
}
// Match raw + canonical to catch gmail-alias collisions, and
// reject on ANY confirmed account (OIDC accounts have
// password=null but are real) — not just password-holders.
const canonical = cleanEmail(email);
const existingEmail =
(await this.stores.user.getByEmail(email)) ??
(await this.stores.user.getByCleanEmail(canonical));
if (
existingEmail &&
existingEmail.id !== user.id &&
(existingEmail.email_confirmed ||
existingEmail.password !== null)
) {
throw new HttpError(400, 'This email is already in use.');
}
// Promote: set username/email/password on the existing row
const password_hash = await bcrypt.hash(password, 8);
const email_confirm_code = String(
crypto.randomInt(100000, 1000000),
);
const email_confirm_token = uuidv4();
await this.stores.user.update(user.id, {
username,
email,
clean_email: cleanEmail(email),
password: password_hash,
email_confirm_code,
email_confirm_token,
email_confirmed: 0,
requires_email_confirmation: 1,
});
// Rename the user's FS home so `/<temp>/Desktop` etc.
// become `/<new>/Desktop`. Without this cascade, any
// subsequent path-based FS lookup against the new
// username would 404.
if (username !== user.username) {
try {
await this.stores.fsEntry.renameUserHome(
user.id,
username,
);
} catch (e) {
console.warn(
'[save-account] fs home rename failed:',
e,
);
}
}
// Move from temp group to user group
if (this.config.default_temp_group) {
try {
await this.stores.group.removeUsers(
this.config.default_temp_group,
[username],
);
} catch {
// Best-effort
}
}
if (this.config.default_user_group) {
try {
await this.stores.group.addUsers(
this.config.default_user_group,
[username],
);
} catch (e) {
console.warn('[save-account] group add failed:', e);
}
}
// Send confirmation email
if (this.clients.email) {
try {
await this.clients.email.send(
email,
'email_verification_code',
{
code: email_confirm_code,
},
);
} catch (e) {
console.warn(
'[save-account] confirmation email failed:',
e,
);
}
}
try {
this.clients.event?.emit(
'user.save_account',
{
user_id: user.id,
old_username: user.username,
new_username: username,
email,
},
{},
);
} catch {
// best-effort
}
const updatedUser = await this.stores.user.getById(user.id, {
force: true,
});
res.json({
user: {
username: updatedUser.username,
uuid: updatedUser.uuid,
email: updatedUser.email,
email_confirmed: updatedUser.email_confirmed,
requires_email_confirmation:
updatedUser.requires_email_confirmation,
is_temp: false,
},
});
},
);
// ── Captcha generation ───────────────────────────────────────
router.get(
'/api/captcha/generate',
{ subdomain: '*' },
async (_req, res) => {
const difficulty = this.config.captcha?.difficulty || 'medium';
const { token, image } = await generateCaptcha(difficulty);
res.json({ token, image });
},
);
// ── Anti-CSRF token generation ──────────────────────────────
router.get(
'/get-anticsrf-token',
{ subdomain: '', requireAuth: true },
async (req, res) => {
const sessionId = req.actor?.user?.uuid;
if (!sessionId)
throw new HttpError(401, 'Authentication required.');
const token = await antiCsrf.createToken(sessionId);
res.json({ token });
},
);
// ── Permission grants ───────────────────────────────────────
router.post(
'/auth/grant-user-user',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const { target_username, permission, extra, meta } = req.body;
if (!target_username || !permission) {
throw new HttpError(
400,
'Missing `target_username` or `permission`',
);
}
await this.services.permission.grantUserUserPermission(
req.actor,
target_username,
permission,
extra,
meta,
);
res.json({});
},
);
router.post(
'/auth/grant-user-app',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const { app_uid, permission, extra, meta } = req.body;
if (!app_uid || !permission) {
throw new HttpError(
400,
'Missing `app_uid` or `permission`',
);
}
await this.services.permission.grantUserAppPermission(
req.actor,
app_uid,
permission,
extra,
meta,
);
res.json({});
},
);
router.post(
'/auth/grant-user-group',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const { group_uid, permission, extra, meta } = req.body;
if (!group_uid || !permission) {
throw new HttpError(
400,
'Missing `group_uid` or `permission`',
);
}
const group = await this.stores.group.getByUid(group_uid);
if (!group) throw new HttpError(404, 'Group not found');
await this.services.permission.grantUserGroupPermission(
req.actor,
group,
permission,
extra,
meta,
);
res.json({});
},
);
// ── Permission revokes ──────────────────────────────────────
router.post(
'/auth/revoke-user-user',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const { target_username, permission, meta } = req.body;
if (!target_username || !permission) {
throw new HttpError(
400,
'Missing `target_username` or `permission`',
);
}
await this.services.permission.revokeUserUserPermission(
req.actor,
target_username,
permission,
meta,
);
res.json({});
},
);
router.post(
'/auth/revoke-user-app',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const { app_uid, permission, meta } = req.body;
if (!app_uid || !permission) {
throw new HttpError(
400,
'Missing `app_uid` or `permission`',
);
}
if (permission === '*') {
await this.services.permission.revokeUserAppAll(
req.actor,
app_uid,
meta,
);
} else {
await this.services.permission.revokeUserAppPermission(
req.actor,
app_uid,
permission,
meta,
);
}
res.json({});
},
);
router.post(
'/auth/revoke-user-group',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const { group_uid, permission, meta } = req.body;
if (!group_uid || !permission) {
throw new HttpError(
400,
'Missing `group_uid` or `permission`',
);
}
await this.services.permission.revokeUserGroupPermission(
req.actor,
{ uid: group_uid },
permission,
meta,
);
res.json({});
},
);
// ── Permission checks ───────────────────────────────────────
router.post(
'/auth/check-permissions',
{ subdomain: 'api', requireAuth: true },
async (req, res) => {
const { permissions } = req.body;
if (!Array.isArray(permissions)) {
throw new HttpError(
400,
'Missing or invalid `permissions` array',
);
}
const unique = [...new Set(permissions)];
const result = {};
let granted;
try {
granted = await this.services.permission.checkMany(
req.actor,
unique,
);
} catch {
granted = new Map();
}
for (const perm of unique) {
result[perm] = granted.get(perm) ?? false;
}
res.json({ permissions: result });
},
);
// ── Session management ──────────────────────────────────────
router.get(
'/auth/list-sessions',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const sessions = await this.services.auth.listSessions(
req.actor,
);
res.json(sessions);
},
);
router.post(
'/auth/revoke-session',
{ subdomain: 'api', requireUserActor: true, antiCsrf: true },
async (req, res) => {
const { uuid } = req.body;
if (!uuid || typeof uuid !== 'string') {
throw new HttpError(400, 'Missing or invalid `uuid`', {
legacyCode: 'bad_request',
});
}
const session = await this.stores.session.getByUuid(uuid);
if (session.user_id !== req.actor.user.id) {
throw new HttpError(
403,
'Can only revoke your own sessions',
{ legacyCode: 'unauthorized' },
);
}
await this.services.auth.revokeSession(uuid);
const sessions = await this.services.auth.listSessions(
req.actor,
);
res.json({ sessions });
},
);
// ── Dev app permissions ──────────────────────────────────────
router.post(
'/auth/grant-dev-app',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
let { app_uid, origin, permission, extra, meta } = req.body;
if (origin && !app_uid) {
app_uid = await this.services.auth.appUidFromOrigin(origin);
}
if (!app_uid || !permission) {
throw new HttpError(
400,
'Missing `app_uid` or `permission`',
);
}
await this.services.permission.grantDevAppPermission(
req.actor,
app_uid,
permission,
extra,
meta,
);
res.json({});
},
);
router.post(
'/auth/revoke-dev-app',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
let { app_uid, origin, permission, meta } = req.body;
if (origin && !app_uid) {
app_uid = await this.services.auth.appUidFromOrigin(origin);
}
if (!app_uid || !permission) {
throw new HttpError(
400,
'Missing `app_uid` or `permission`',
);
}
if (permission === '*') {
await this.services.permission.revokeDevAppAll(
req.actor,
app_uid,
meta,
);
}
await this.services.permission.revokeDevAppPermission(
req.actor,
app_uid,
permission,
meta,
);
res.json({});
},
);
// ── Permission listing ──────────────────────────────────────
router.get(
'/auth/list-permissions',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const userId = req.actor.user.id;
const db = this.clients.db;
const [appPerms, userPermsOut, userPermsIn] = await Promise.all(
[
db.read(
'SELECT `app_uid`, `permission`, `extra` FROM `user_to_app_permissions` WHERE `user_id` = ?',
[userId],
),
db.read(
'SELECT u.`username`, p.`permission`, p.`extra` FROM `user_to_user_permissions` p ' +
'JOIN `user` u ON u.`id` = p.`target_user_id` WHERE p.`issuer_user_id` = ?',
[userId],
),
db.read(
'SELECT u.`username`, p.`permission`, p.`extra` FROM `user_to_user_permissions` p ' +
'JOIN `user` u ON u.`id` = p.`issuer_user_id` WHERE p.`target_user_id` = ?',
[userId],
),
],
);
res.json({
myself_to_app: appPerms.map((r) => ({
app_uid: r.app_uid,
permission: r.permission,
extra:
typeof r.extra === 'string'
? JSON.parse(r.extra)
: (r.extra ?? {}),
})),
myself_to_user: userPermsOut.map((r) => ({
user: r.username,
permission: r.permission,
extra:
typeof r.extra === 'string'
? JSON.parse(r.extra)
: (r.extra ?? {}),
})),
user_to_myself: userPermsIn.map((r) => ({
user: r.username,
permission: r.permission,
extra:
typeof r.extra === 'string'
? JSON.parse(r.extra)
: (r.extra ?? {}),
})),
});
},
);
// ── App origin resolution ───────────────────────────────────
router.post(
'/auth/app-uid-from-origin',
{ subdomain: 'api', requireAuth: true },
async (req, res) => {
const origin = req.body?.origin || req.query?.origin;
if (!origin) throw new HttpError(400, 'Missing `origin`');
const uid = await this.services.auth.appUidFromOrigin(origin);
res.json({ uid });
},
);
// ── App token + check ───────────────────────────────────────
router.post(
'/auth/get-user-app-token',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
let { app_uid, origin } = req.body;
if (!app_uid && origin) {
app_uid = await this.services.auth.appUidFromOrigin(origin);
}
if (!app_uid) {
throw new HttpError(400, 'Missing `app_uid` or `origin`');
}
const app = await this.stores.app.getByUid(app_uid);
if (!app) {
throw new HttpError(404, `App ${app_uid} does not exist`);
}
// Grant the app-is-authenticated flag
const userPermGrantPromise =
await this.services.permission.grantUserAppPermission(
req.actor,
app_uid,
'flag:app-is-authenticated',
{},
{},
);
const token = this.services.auth.getUserAppToken(
req.actor,
app_uid,
);
const missingFSPathPromise = (async () => {
// Ensure the app's per-user AppData directory exists.
// v1 did this in LLMkdir with the app icon as thumbnail
// on first app open. mkdir is idempotent (returns
// existing dir without rewriting), and
// createMissingParents seeds `/<username>/AppData` if
// the user never had one. Path lookups in FSEntryStore
// have a recursive-CTE fallback (mirrors v1's
// `convert_path_to_fsentry` walk-down) so legacy rows
// with a NULL `path` column still resolve and get
// backfilled on first read.
const username = req.actor.user?.username;
const userId = req.actor.user?.id;
if (username && userId) {
await this.services.fs.mkdir(userId, {
path: `/${username}/AppData/${app_uid}`,
createMissingParents: true,
thumbnail: app.icon ?? null,
});
}
})();
await Promise.all([userPermGrantPromise, missingFSPathPromise]);
res.json({ token, app_uid });
},
);
router.post(
'/auth/check-app',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
let { app_uid, origin } = req.body;
if (!app_uid && origin) {
app_uid = await this.services.auth.appUidFromOrigin(origin);
}
if (!app_uid)
throw new HttpError(400, 'Missing `app_uid` or `origin`');
// Check if the app is authenticated for this user
const authenticated = await this.services.permission
.check(
req.actor,
`service:${app_uid}:ii:flag:app-is-authenticated`,
)
.catch(() => false);
const result = { app_uid, authenticated };
if (authenticated) {
result.token = this.services.auth.getUserAppToken(
req.actor,
app_uid,
);
}
res.json(result);
},
);
// ── Access tokens ───────────────────────────────────────────
router.post(
'/auth/create-access-token',
{ subdomain: 'api', requireAuth: true },
async (req, res) => {
const { permissions, expiresIn } = req.body;
if (!Array.isArray(permissions) || permissions.length === 0) {
throw new HttpError(
400,
'Missing or empty `permissions` array',
);
}
// Normalize specs: string → [string], [string] → [string, {}], [string, extra] → as-is
const normalized = permissions.map((spec) => {
if (typeof spec === 'string') return [spec];
if (Array.isArray(spec)) return spec;
throw new HttpError(
400,
'Each permission must be a string or [string, extra?]',
);
});
const token = await this.services.auth.createAccessToken(
req.actor,
normalized,
expiresIn ? { expiresIn } : {},
);
res.json({ token });
},
);
router.post(
'/auth/revoke-access-token',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
let { tokenOrUuid } = req.body;
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
throw new HttpError(400, 'Missing `tokenOrUuid`');
}
// Extract JWT from /token-read URLs if needed
if (tokenOrUuid.includes('/token-read')) {
const match = tokenOrUuid.match(/\/token-read\/([^\s/?]+)/);
if (match) tokenOrUuid = match[1];
}
await this.services.auth.revokeAccessToken(
req.actor,
tokenOrUuid,
);
res.json({ ok: true });
},
);
// ── 2FA: configure ─────────────────────────────────────────────
router.post(
'/auth/configure-2fa/:action',
{
subdomain: 'api',
requireUserActor: true,
},
async (req, res) => {
const action = req.params.action;
const user = await this.stores.user.getById(req.actor.user.id, {
force: true,
});
if (!user) throw new HttpError(404, 'User not found');
if (action === 'setup') {
if (user.otp_enabled) {
throw new HttpError(409, '2FA is already enabled.');
}
const result = otpCreateSecret(user.username);
// Generate 10 recovery codes
const codes = [];
for (let i = 0; i < 10; i++) {
codes.push(createRecoveryCode());
}
const hashedCodes = codes.map((c) => hashRecoveryCode(c));
await this.clients.db.write(
'UPDATE `user` SET `otp_secret` = ?, `otp_recovery_codes` = ? WHERE `uuid` = ?',
[result.secret, hashedCodes.join(','), user.uuid],
);
await this.stores.user.invalidateById(user.id);
return res.json({
url: result.url,
secret: result.secret,
codes,
});
}
if (action === 'test') {
const { code } = req.body ?? {};
if (!code) throw new HttpError(400, 'Missing `code`');
const ok = verifyOtp(user.username, user.otp_secret, code);
return res.json({ ok });
}
if (action === 'enable') {
if (!user.email_confirmed) {
throw new HttpError(
403,
'Email must be confirmed before enabling 2FA.',
);
}
if (user.otp_enabled) {
throw new HttpError(409, '2FA is already enabled.');
}
if (!user.otp_secret) {
throw new HttpError(
409,
'2FA has not been configured. Call setup first.',
);
}
await this.clients.db.write(
'UPDATE `user` SET `otp_enabled` = 1 WHERE `uuid` = ?',
[user.uuid],
);
await this.stores.user.invalidateById(user.id);
if (this.clients.email && user.email) {
try {
await this.clients.email.send(
user.email,
'enabled_2fa',
{
username: user.username,
},
);
} catch (e) {
console.warn(
'[configure-2fa] email send failed:',
e,
);
}
}
return res.json({});
}
throw new HttpError(400, `Invalid action: ${action}`);
},
);
// ── 2FA: disable ───────────────────────────────────────────────
router.post(
'/user-protected/disable-2fa',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'disable-2fa',
limit: 10,
window: 60 * 60_000,
key: 'user',
},
middleware: createUserProtectedGate(userProtectedDeps),
},
async (req, res) => {
const user = await this.stores.user.getById(req.actor.user.id, {
force: true,
});
if (!user) throw new HttpError(404, 'User not found');
await this.clients.db.write(
'UPDATE `user` SET `otp_enabled` = 0, `otp_recovery_codes` = NULL, `otp_secret` = NULL WHERE `uuid` = ?',
[user.uuid],
);
await this.stores.user.invalidateById(user.id);
if (this.clients.email && user.email) {
try {
await this.clients.email.send(
user.email,
'disabled_2fa',
{
username: user.username,
},
);
} catch (e) {
console.warn('[disable-2fa] email send failed:', e);
}
}
res.json({ success: true });
},
);
// ── Developer profile ──────────────────────────────────────────
router.get(
'/get-dev-profile',
{
subdomain: 'api',
requireUserActor: true,
},
async (req, res) => {
const user = await this.stores.user.getById(req.actor.user.id, {
force: true,
});
if (!user) throw new HttpError(404, 'User not found');
res.json({
first_name: user.first_name ?? null,
last_name: user.last_name ?? null,
approved_for_incentive_program: Boolean(
user.approved_for_incentive_program,
),
joined_incentive_program: Boolean(
user.joined_incentive_program,
),
paypal: user.paypal ?? null,
});
},
);
// ── Group management ───────────────────────────────────────────
router.post(
'/group/create',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const extra = req.body.extra ?? {};
const metadata = req.body.metadata ?? {};
if (typeof extra !== 'object' || Array.isArray(extra))
throw new HttpError(400, '`extra` must be an object');
if (typeof metadata !== 'object' || Array.isArray(metadata))
throw new HttpError(400, '`metadata` must be an object');
const uid = await this.stores.group.create({
ownerUserId: req.actor.user.id,
extra: {},
metadata,
});
res.json({ uid });
},
);
router.post(
'/group/add-users',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const { uid, users } = req.body ?? {};
if (!uid) throw new HttpError(400, 'Missing `uid`');
if (!Array.isArray(users))
throw new HttpError(400, '`users` must be an array');
const group = await this.stores.group.getByUid(uid);
if (!group) throw new HttpError(404, 'Group not found');
if (group.owner_user_id !== req.actor.user.id)
throw new HttpError(403, 'Forbidden');
await this.stores.group.addUsers(uid, users);
res.json({});
},
);
router.post(
'/group/remove-users',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const { uid, users } = req.body ?? {};
if (!uid) throw new HttpError(400, 'Missing `uid`');
if (!Array.isArray(users))
throw new HttpError(400, '`users` must be an array');
const group = await this.stores.group.getByUid(uid);
if (!group) throw new HttpError(404, 'Group not found');
if (group.owner_user_id !== req.actor.user.id)
throw new HttpError(403, 'Forbidden');
await this.stores.group.removeUsers(uid, users);
res.json({});
},
);
router.get(
'/group/list',
{ subdomain: 'api', requireUserActor: true },
async (req, res) => {
const userId = req.actor.user.id;
const [owned, member] = await Promise.all([
this.stores.group.listByOwner(userId),
this.stores.group.listByMember(userId),
]);
res.json({
owned_groups: owned,
in_groups: member,
});
},
);
router.get(
'/group/public-groups',
{ subdomain: 'api' },
async (_req, res) => {
res.json({
user: this.config.default_user_group ?? null,
temp: this.config.default_temp_group ?? null,
});
},
);
// ── Session helpers ────────────────────────────────────────────
router.get(
'/get-gui-token',
{ subdomain: ['api', ''], requireUserActor: true },
async (req, res) => {
if (!req.actor?.session?.uid)
throw new HttpError(400, 'No session bound to this actor');
const user = await this.stores.user.getById(req.actor.user.id);
if (!user) throw new HttpError(404, 'User not found');
const guiToken = this.services.auth.createGuiToken(
user,
req.actor.session.uid,
);
res.json({ token: guiToken });
},
);
router.get(
'/session/sync-cookie',
{ subdomain: ['api', ''], requireUserActor: true },
async (req, res) => {
if (!req.actor?.session?.uid) {
res.status(400).end();
return;
}
const user = await this.stores.user.getById(req.actor.user.id);
if (!user) {
res.status(404).end();
return;
}
const sessionToken =
this.services.auth.createSessionTokenForSession(
user,
req.actor.session.uid,
);
res.cookie(this.config.cookie_name, sessionToken, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
res.status(204).end();
},
);
// ── Delete own account ─────────────────────────────────────────
//
// Purge S3 objects + fsentries first, then the user row. FK
// cascades on most related tables are `ON DELETE SET NULL` (not
// CASCADE), so anything holding tightly to user_id (sessions) we
// clear explicitly to avoid orphan rows.
router.post(
'/user-protected/delete-own-user',
{
subdomain: ['api', ''],
requireUserActor: true,
middleware: createUserProtectedGate(userProtectedDeps, {
allowTempUsers: true,
}),
},
async (req, res) => {
const userId = req.actor.user.id;
res.clearCookie(this.config.cookie_name);
res.clearCookie('puter_revalidation');
await this.#cascadeDeleteUser(userId);
res.json({ success: true });
},
);
}
async #cascadeDeleteUser(userId) {
try {
await this.services.fs.removeAllForUser(userId);
} catch (e) {
// Proceed with user-row delete anyway — orphaned fsentries are
// better than a resurrected account.
console.warn('[cascade-delete-user] fs cleanup failed:', e);
}
// Sessions FK is SET NULL, so delete explicitly to avoid dangling rows.
await this.clients.db.write(
'DELETE FROM `sessions` WHERE `user_id` = ?',
[userId],
);
await this.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
userId,
]);
await this.stores.user.invalidateById(userId);
}
// ── Helpers ──────────────────────────────────────────────────────
async #generateRandomUsername() {
let username;
let attempts = 0;
do {
username = generate_identifier();
attempts++;
if (attempts > 20)
throw new Error('Failed to generate unique username');
} while (await this.stores.user.getByUsername(username));
return username;
}
/**
* Config-blocklist + extension-driven email validation.
* Config blocklist (suffix match on cleaned email) blocks first; then
* the `puter.email.validate` event lets extensions (abuse) reject.
* Throws HttpError(400) on rejection.
*/
async #validateEmail(email) {
if (isBlockedEmail(email, this.config.blockedEmailDomains)) {
throw new HttpError(400, 'This email is not allowed.');
}
const validateEvent = {
email: cleanEmail(email),
allow: true,
message: null,
};
try {
await this.clients.event?.emitAndWait(
'email.validate',
validateEvent,
{},
);
} catch (e) {
console.warn('[email-validate] hook failed:', e);
}
if (!validateEvent.allow) {
throw new HttpError(
400,
validateEvent.message ??
'This email cannot be used. Please try a different email address.',
);
}
}
async #completeLogin(req, res, user) {
const meta = {
ip: req.ip || req.socket?.remoteAddress,
user_agent: req.headers?.['user-agent'],
origin: req.headers?.origin,
host: req.headers?.host,
};
const { token: sessionToken, gui_token } =
await this.services.auth.createSessionToken(user, meta);
// HTTP-only cookie gets the session token
res.cookie(this.config.cookie_name, sessionToken, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
// Resolve taskbar items up-front so the GUI doesn't need a second
// round-trip on first paint. Best-effort: a failure here shouldn't
// block login (the client can still fetch them via /whoami later).
let taskbar_items = [];
try {
taskbar_items = await getTaskbarItems(user, {
clients: this.clients,
stores: this.stores,
services: this.services,
apiBaseUrl: this.config.api_base_url,
});
} catch (e) {
console.warn('[auth] taskbar_items resolution failed:', e);
}
// Response body gets the GUI token (client never sees session token)
return res.json({
proceed: true,
next_step: 'complete',
token: gui_token,
user: {
username: user.username,
uuid: user.uuid,
email: user.email,
email_confirmed: user.email_confirmed,
requires_email_confirmation: user.requires_email_confirmation,
is_temp: user.password === null && user.email === null,
taskbar_items,
},
});
}
onServerStart() {}
onServerPrepareShutdown() {}
onServerShutdown() {}
}