mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
The opt-in multi-user feature's only enforcement is web-layer scoping (all sessions share one OS account). An adversarial review found 8 critical + 7 high cross-user holes that defeated it, plus mediums; all fixed here. Single-user (flag-off) behavior stays byte-identical apart from documented consistency deltas. Ownership / confinement: - DELETE /api/sessions (bulk) + /:id now owner-scope / findSessionOrFail - quick-start, cron (create+fire), scheduled runs confine workingDir to the owner's space; case link/docker-link/docker-import confine the host path - resolveCasePath no longer resolves linked cases for non-admins; foreign remote/docker cases are skipped (fall through to the caller's own local case) - history, subagents/workflows, mux-sessions, orchestrator, cron run-history, away-digest, and remote/docker host reads are owner- or admin-scoped Permission policy (section 6.3): - non-granted users are downgraded at every spawn site incl. legacy /api/scheduled, PlanOrchestrator one-shots, remote launch, and the cron-fire gemini/codex bypass switches; resolveClaudeModeForUsername now fails closed Auth / store: - verify-first login throttle (a correct password is never locked out), /ws terminal subject to the change-password lockbox, cookie fast-path re-validates identity live, role/grant changes revoke sessions, admin delete runs the last-admin guard before any teardown - users.json: distinguish missing (ENOENT) from corrupt/unreadable so a bad read can't overwrite all accounts; unique per-process temp write path Event streams: - debounced session:updated + batched task:updated, clipboard, and push notifications route by owner (fail closed); getLightState hides machine-wide globalStats from non-admins Tests: two suites updated to assert the fixed (secure) behavior. tsc, eslint, and test:ci all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
489 lines
19 KiB
TypeScript
489 lines
19 KiB
TypeScript
/**
|
|
* @fileoverview Multi-user store: `~/.codeman/users.json` (via `dataPath`, 0600).
|
|
*
|
|
* Mirrors the storage-module pattern of `remote-hosts.ts` / `docker-hosts.ts`, but
|
|
* because it holds password hashes it writes atomically (tmp + rename) at mode
|
|
* 0600 and keeps only a SHORT in-process cache so the CLI (`codeman users …`) can
|
|
* edit the file while the server runs and have changes picked up within the TTL.
|
|
*
|
|
* Pure, IO-free helpers (`isValidUsername`, `hashPassword`, `verifyPasswordHash`,
|
|
* `needsRehash`, `resolveClaudeModeForUser`, the last-admin invariants) are split
|
|
* out so they are unit-testable without a server. Hashing is `scrypt` from
|
|
* `node:crypto` (no new deps), compared via `timingSafeEqual`; parameters are
|
|
* stored per record so cost can be raised later and old records rehashed on their
|
|
* next successful login.
|
|
*
|
|
* See `docs/multi-user-plan.md` sections 4.1, 5, 6.3.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
import fs from 'node:fs/promises';
|
|
import { isAbsolute, join, relative } from 'node:path';
|
|
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
|
|
import { promisify } from 'node:util';
|
|
import { dataPath, getDataDir } from './config/instance.js';
|
|
import { getUserSpacesDir, isMultiUserMode, maxUsers } from './config/multiuser.js';
|
|
import type { AuthUser, ClaudeMode, PasswordHash, PublicUser, UserRecord, UserRole, UsersFile } from './types.js';
|
|
|
|
const scrypt = promisify(scryptCb) as (
|
|
password: string | Buffer,
|
|
salt: string | Buffer,
|
|
keylen: number,
|
|
options: { N: number; r: number; p: number; maxmem: number }
|
|
) => Promise<Buffer>;
|
|
|
|
const USERS_FILE = 'users.json';
|
|
const CACHE_TTL_MS = 1000;
|
|
const KEYLEN = 64;
|
|
const SALT_BYTES = 32;
|
|
/** Generous ceiling so raising N/r later does not trip scrypt's memory guard. */
|
|
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
|
|
|
/** Current hashing parameters. Stored per record; raise these to increase cost. */
|
|
export const DEFAULT_SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } as const;
|
|
|
|
/** Username: lowercase, first char alphanumeric, 2-32 chars total. Becomes a folder name. */
|
|
const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,31}$/;
|
|
|
|
/** Typed error whose `.code` maps to an API errorCode at the route layer. */
|
|
export class UserStoreError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly code: 'USER_EXISTS' | 'USER_NOT_FOUND' | 'LAST_ADMIN' | 'INVALID_INPUT'
|
|
) {
|
|
super(message);
|
|
this.name = 'UserStoreError';
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────── pure helpers ───────────────────────────────
|
|
|
|
export function normalizeUsername(name: string): string {
|
|
return String(name ?? '')
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
export function isValidUsername(name: string): boolean {
|
|
return USERNAME_RE.test(normalizeUsername(name));
|
|
}
|
|
|
|
/** Hash a password with the given (or current) scrypt params + a fresh random salt. */
|
|
export async function hashPassword(
|
|
password: string,
|
|
params: { N: number; r: number; p: number } = DEFAULT_SCRYPT_PARAMS
|
|
): Promise<PasswordHash> {
|
|
const salt = randomBytes(SALT_BYTES);
|
|
const derived = await scrypt(password, salt, KEYLEN, { ...params, maxmem: SCRYPT_MAXMEM });
|
|
return {
|
|
algo: 'scrypt',
|
|
N: params.N,
|
|
r: params.r,
|
|
p: params.p,
|
|
salt: salt.toString('hex'),
|
|
hash: derived.toString('hex'),
|
|
};
|
|
}
|
|
|
|
/** Constant-time verify of a password against a stored hash record. Never throws. */
|
|
export async function verifyPasswordHash(password: string, record: PasswordHash): Promise<boolean> {
|
|
if (!record || record.algo !== 'scrypt') return false;
|
|
let salt: Buffer;
|
|
let expected: Buffer;
|
|
try {
|
|
salt = Buffer.from(record.salt, 'hex');
|
|
expected = Buffer.from(record.hash, 'hex');
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (expected.length === 0) return false;
|
|
let derived: Buffer;
|
|
try {
|
|
derived = await scrypt(password, salt, expected.length, {
|
|
N: record.N,
|
|
r: record.r,
|
|
p: record.p,
|
|
maxmem: SCRYPT_MAXMEM,
|
|
});
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (derived.length !== expected.length) return false;
|
|
return timingSafeEqual(derived, expected);
|
|
}
|
|
|
|
/** True when a stored hash uses weaker params than current and should be rehashed. */
|
|
export function needsRehash(record: PasswordHash, params = DEFAULT_SCRYPT_PARAMS): boolean {
|
|
return record.algo !== 'scrypt' || record.N !== params.N || record.r !== params.r || record.p !== params.p;
|
|
}
|
|
|
|
/** URL-safe one-time password (16 chars) for admin create/reset flows. */
|
|
export function generateOneTimePassword(): string {
|
|
return randomBytes(12).toString('base64url');
|
|
}
|
|
|
|
export function toPublicUser(u: UserRecord): PublicUser {
|
|
return {
|
|
username: u.username,
|
|
role: u.role,
|
|
disabled: !!u.disabled,
|
|
mustChangePassword: !!u.mustChangePassword,
|
|
canBypassPermissions: !!u.canBypassPermissions,
|
|
createdAt: u.createdAt,
|
|
lastLoginAt: u.lastLoginAt,
|
|
};
|
|
}
|
|
|
|
export function countEnabledAdmins(users: UserRecord[]): number {
|
|
return users.filter((u) => u.role === 'admin' && !u.disabled).length;
|
|
}
|
|
|
|
/**
|
|
* Section 6.3: resolve the effective Claude permission mode for a user. Admins and
|
|
* granted users get the global mode as-is; a non-granted regular user whose mode
|
|
* would be `dangerously-skip-permissions` is silently downgraded to `auto` (all
|
|
* other modes are already <= auto and pass through). Pure.
|
|
*/
|
|
export function resolveClaudeModeForUser(
|
|
globalMode: ClaudeMode | undefined,
|
|
grant: { role: UserRole; canBypassPermissions?: boolean }
|
|
): ClaudeMode {
|
|
const mode: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
|
|
if (grant.role === 'admin' || grant.canBypassPermissions) return mode;
|
|
return mode === 'dangerously-skip-permissions' ? 'auto' : mode;
|
|
}
|
|
|
|
/**
|
|
* Section 6.3: whether a user may run arbitrary commands as the host account
|
|
* (shell-mode sessions, cron `launchCommand`, other CLIs' bypass flags). Same
|
|
* one-bit grant as bypass. Admins always may.
|
|
*/
|
|
export function canRunPrivilegedCommands(grant: { role: UserRole; canBypassPermissions?: boolean }): boolean {
|
|
return grant.role === 'admin' || !!grant.canBypassPermissions;
|
|
}
|
|
|
|
// ─────────────────────────────── IO layer ───────────────────────────────
|
|
|
|
let cache: { users: UserRecord[]; ts: number } | null = null;
|
|
|
|
/** Drop the in-process cache (called after every write; exported for tests). */
|
|
export function invalidateUsersCache(): void {
|
|
cache = null;
|
|
}
|
|
|
|
export async function readUsers(force = false): Promise<UserRecord[]> {
|
|
const now = Date.now();
|
|
if (!force && cache && now - cache.ts < CACHE_TTL_MS) return cache.users;
|
|
let raw: string;
|
|
try {
|
|
raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8');
|
|
} catch (err) {
|
|
// ENOENT is the ONLY legitimately-empty store (first boot). Any other read
|
|
// error (EIO/EACCES/EMFILE/EBUSY) is a transient/permission failure, NOT an
|
|
// empty store — do NOT cache [] and do NOT let it look empty, or a following
|
|
// createUser/bootstrap would overwrite users.json and destroy every account.
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
cache = { users: [], ts: now };
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
// A present-but-corrupt file (invalid JSON) must also fail loud rather than
|
|
// read as empty, so mutators/bootstrap abort instead of clobbering it.
|
|
const parsed = JSON.parse(raw) as Partial<UsersFile>;
|
|
const users = Array.isArray(parsed.users) ? parsed.users : [];
|
|
cache = { users, ts: now };
|
|
return users;
|
|
}
|
|
|
|
async function writeUsers(users: UserRecord[]): Promise<void> {
|
|
const dir = getDataDir();
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
const finalPath = dataPath(USERS_FILE);
|
|
// Unique per-writer tmp name (pid + random) so the CLI (`codeman users …`) and
|
|
// the live server — designed to write this file concurrently across processes —
|
|
// never share a single `users.json.tmp` inode and tear each other's payload.
|
|
// Matches the state-store.ts / self-update.ts convention.
|
|
const tmpPath = `${finalPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
const payload: UsersFile = { version: 1, users };
|
|
try {
|
|
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
await fs.chmod(tmpPath, 0o600).catch(() => {});
|
|
await fs.rename(tmpPath, finalPath);
|
|
} catch (err) {
|
|
await fs.unlink(tmpPath).catch(() => {});
|
|
throw err;
|
|
}
|
|
cache = { users, ts: Date.now() };
|
|
}
|
|
|
|
/**
|
|
* Serialize every read-modify-write on users.json. Without this a fire-and-forget
|
|
* touchLastLogin (fired on each Basic auth) can interleave with a route's
|
|
* create/update and clobber records, since both do readUsers(true) → mutate →
|
|
* writeUsers against a single shared file + tmp path.
|
|
*/
|
|
let mutateChain: Promise<unknown> = Promise.resolve();
|
|
function withUsersLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
const run = mutateChain.then(fn, fn);
|
|
mutateChain = run.then(
|
|
() => undefined,
|
|
() => undefined
|
|
);
|
|
return run;
|
|
}
|
|
|
|
export async function hasUsers(): Promise<boolean> {
|
|
return (await readUsers()).length > 0;
|
|
}
|
|
|
|
// A precomputed dummy hash so an unknown/disabled user costs the same scrypt work
|
|
// as a real verify (defeats username-enumeration by timing). Created once, lazily.
|
|
let dummyHashPromise: Promise<PasswordHash> | null = null;
|
|
function getDummyHash(): Promise<PasswordHash> {
|
|
if (!dummyHashPromise) dummyHashPromise = hashPassword('codeman-timing-equalization-placeholder');
|
|
return dummyHashPromise;
|
|
}
|
|
|
|
/**
|
|
* Verify a username/password against the store. Returns the record (plus whether it
|
|
* should be rehashed) on success, or null for wrong password / unknown / disabled
|
|
* user. Runs a dummy scrypt on the miss path so timing does not reveal which users
|
|
* exist. Never writes (the caller decides when to persist lastLogin / rehash).
|
|
*/
|
|
export async function verifyPassword(
|
|
username: string,
|
|
password: string
|
|
): Promise<{ user: UserRecord; needsRehash: boolean } | null> {
|
|
const user = await findUser(username);
|
|
if (!user || user.disabled) {
|
|
await verifyPasswordHash(password, await getDummyHash());
|
|
return null;
|
|
}
|
|
const ok = await verifyPasswordHash(password, user.password);
|
|
if (!ok) return null;
|
|
return { user, needsRehash: needsRehash(user.password) };
|
|
}
|
|
|
|
export async function findUser(username: string): Promise<UserRecord | undefined> {
|
|
const norm = normalizeUsername(username);
|
|
if (!norm) return undefined;
|
|
const users = await readUsers();
|
|
return users.find((u) => u.username === norm);
|
|
}
|
|
|
|
export interface CreateUserOptions {
|
|
username: string;
|
|
role: UserRole;
|
|
password: string;
|
|
mustChangePassword?: boolean;
|
|
canBypassPermissions?: boolean;
|
|
}
|
|
|
|
export async function createUser(opts: CreateUserOptions): Promise<UserRecord> {
|
|
const username = normalizeUsername(opts.username);
|
|
if (!isValidUsername(username)) {
|
|
throw new UserStoreError(
|
|
'Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])',
|
|
'INVALID_INPUT'
|
|
);
|
|
}
|
|
if (opts.role !== 'admin' && opts.role !== 'user') {
|
|
throw new UserStoreError('Role must be "admin" or "user"', 'INVALID_INPUT');
|
|
}
|
|
if (!opts.password || opts.password.length < 8) {
|
|
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
|
}
|
|
return withUsersLock(async () => {
|
|
const users = await readUsers(true);
|
|
if (users.some((u) => u.username === username)) {
|
|
throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS');
|
|
}
|
|
if (users.length >= maxUsers()) {
|
|
throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT');
|
|
}
|
|
const record: UserRecord = {
|
|
username,
|
|
role: opts.role,
|
|
password: await hashPassword(opts.password),
|
|
disabled: false,
|
|
mustChangePassword: !!opts.mustChangePassword,
|
|
canBypassPermissions: !!opts.canBypassPermissions,
|
|
createdAt: Date.now(),
|
|
};
|
|
users.push(record);
|
|
await writeUsers(users);
|
|
return record;
|
|
});
|
|
}
|
|
|
|
/** Set a user's password. `mustChangePassword` is left unchanged unless specified. */
|
|
export async function setPassword(
|
|
username: string,
|
|
password: string,
|
|
opts: { mustChangePassword?: boolean } = {}
|
|
): Promise<UserRecord> {
|
|
if (!password || password.length < 8) {
|
|
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
|
}
|
|
const norm = normalizeUsername(username);
|
|
return withUsersLock(async () => {
|
|
const users = await readUsers(true);
|
|
const record = users.find((u) => u.username === norm);
|
|
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
|
record.password = await hashPassword(password);
|
|
if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword;
|
|
await writeUsers(users);
|
|
return record;
|
|
});
|
|
}
|
|
|
|
export interface UpdateUserPatch {
|
|
role?: UserRole;
|
|
disabled?: boolean;
|
|
canBypassPermissions?: boolean;
|
|
mustChangePassword?: boolean;
|
|
}
|
|
|
|
export async function updateUser(username: string, patch: UpdateUserPatch): Promise<UserRecord> {
|
|
const norm = normalizeUsername(username);
|
|
return withUsersLock(async () => {
|
|
const users = await readUsers(true);
|
|
const record = users.find((u) => u.username === norm);
|
|
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
|
|
|
// Guard the last-enabled-admin invariant against demote/disable.
|
|
const before = countEnabledAdmins(users);
|
|
const projected: UserRecord = {
|
|
...record,
|
|
role: patch.role ?? record.role,
|
|
disabled: patch.disabled ?? record.disabled,
|
|
};
|
|
const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u)));
|
|
if (before > 0 && after === 0) {
|
|
throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN');
|
|
}
|
|
|
|
if (patch.role !== undefined) record.role = patch.role;
|
|
if (patch.disabled !== undefined) record.disabled = patch.disabled;
|
|
if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions;
|
|
if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword;
|
|
await writeUsers(users);
|
|
return record;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Record a successful login timestamp. Best-effort + throttled: skips the write if
|
|
* the last login was within the last minute (Basic clients re-send credentials on
|
|
* every request, so this fires often — the throttle keeps disk churn bounded).
|
|
*/
|
|
export async function touchLastLogin(username: string): Promise<void> {
|
|
const norm = normalizeUsername(username);
|
|
try {
|
|
await withUsersLock(async () => {
|
|
const users = await readUsers(true);
|
|
const record = users.find((u) => u.username === norm);
|
|
if (!record) return;
|
|
if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return;
|
|
record.lastLoginAt = Date.now();
|
|
await writeUsers(users);
|
|
});
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
export async function deleteUser(username: string): Promise<void> {
|
|
const norm = normalizeUsername(username);
|
|
await withUsersLock(async () => {
|
|
const users = await readUsers(true);
|
|
const record = users.find((u) => u.username === norm);
|
|
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
|
const before = countEnabledAdmins(users);
|
|
const remaining = users.filter((u) => u.username !== norm);
|
|
const after = countEnabledAdmins(remaining);
|
|
if (before > 0 && after === 0) {
|
|
throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN');
|
|
}
|
|
await writeUsers(remaining);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* First-boot bootstrap: in multi-user mode with no users yet, create the initial
|
|
* admin from `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` if both are set. Returns a
|
|
* status the caller (server start / CLI) uses to decide whether to refuse boot.
|
|
*/
|
|
export async function bootstrapInitialAdmin(): Promise<{
|
|
status: 'created' | 'exists' | 'missing-env';
|
|
username?: string;
|
|
}> {
|
|
if (await hasUsers()) return { status: 'exists' };
|
|
const username = process.env.CODEMAN_USERNAME;
|
|
const password = process.env.CODEMAN_PASSWORD;
|
|
if (!username || !password) return { status: 'missing-env' };
|
|
const created = await createUser({ username, role: 'admin', password });
|
|
return { status: 'created', username: created.username };
|
|
}
|
|
|
|
/**
|
|
* Delete a user's on-disk space (`<USER_SPACES_DIR>/<username>`) with the section 8
|
|
* guard rails: the top-level dir must not be a symlink, and its realpath must
|
|
* resolve strictly inside USER_SPACES_DIR (so a symlinked or `..`-escaping target
|
|
* can never be used to rm an arbitrary tree). No-op if the space does not exist.
|
|
*/
|
|
export async function deleteUserSpace(username: string): Promise<void> {
|
|
const norm = normalizeUsername(username);
|
|
if (!isValidUsername(norm)) throw new UserStoreError('Invalid username', 'INVALID_INPUT');
|
|
const root = getUserSpacesDir();
|
|
const target = join(root, norm);
|
|
let lst;
|
|
try {
|
|
lst = await fs.lstat(target);
|
|
} catch {
|
|
return; // nothing to delete
|
|
}
|
|
if (lst.isSymbolicLink()) {
|
|
throw new UserStoreError('Refusing to delete a symlinked user space', 'INVALID_INPUT');
|
|
}
|
|
const realRoot = await fs.realpath(root).catch(() => root);
|
|
const realTarget = await fs.realpath(target);
|
|
const rel = relative(realRoot, realTarget);
|
|
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
throw new UserStoreError('User space escapes USER_SPACES_DIR', 'INVALID_INPUT');
|
|
}
|
|
await fs.rm(realTarget, { recursive: true, force: true });
|
|
}
|
|
|
|
/** The synthetic admin used in single-user mode so downstream has one code path. */
|
|
export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' };
|
|
|
|
/**
|
|
* Whether a username may run arbitrary commands (shell mode, cron launchCommand,
|
|
* other CLIs' bypass). Single-user or an unset owner: allowed. In multi-user a
|
|
* MISSING user (e.g. deleted) fails closed (non-privileged). Used at cron fire time.
|
|
*/
|
|
export async function canUsernameRunPrivilegedCommands(username: string | undefined): Promise<boolean> {
|
|
if (!isMultiUserMode() || !username) return true;
|
|
const user = await findUser(username);
|
|
return canRunPrivilegedCommands(user ?? { role: 'user' });
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective Claude mode for a username by looking up the grant. In
|
|
* single-user mode (or for an unknown owner) the global mode passes through.
|
|
*/
|
|
export async function resolveClaudeModeForUsername(
|
|
globalMode: ClaudeMode | undefined,
|
|
username: string | undefined
|
|
): Promise<ClaudeMode> {
|
|
const fallback: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
|
|
if (!isMultiUserMode() || !username) return fallback;
|
|
// Fail closed: an unknown/deleted owner in multi-user mode is treated as a
|
|
// non-granted regular user so a stale-owned spawn (e.g. an orphaned cron job)
|
|
// is downgraded to `auto` rather than inheriting the global bypass.
|
|
const user = await findUser(username);
|
|
return resolveClaudeModeForUser(globalMode, user ?? { role: 'user' });
|
|
}
|