From 387897769a77a339d9debcb990f828d2b19f46b9 Mon Sep 17 00:00:00 2001 From: chadbyte Date: Wed, 1 Jul 2026 20:02:55 +1200 Subject: [PATCH 1/4] feat(auth): solo auto-login safety net for absorbed single-user (phase 1) Groundwork for folding single-user mode into multi-user. When a multi-user instance has exactly one user with no PIN, authenticate them automatically instead of showing a login wall, preserving the frictionless single-user onramp. Central change: getMultiUserFromReq() falls back to users.getSoloNoPinUser() when there's no valid token, so every HTTP and WS auth gate (all route through this) is covered by one path. Disabled the moment a PIN is set or a second user is added. Inert today: only triggers when multiUser is true (single-user deploys are unaffected until the later migration phase flips them). --- lib/server-auth.js | 19 ++++++++++++++----- lib/users.js | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/server-auth.js b/lib/server-auth.js index df7e84e4..ba70f93f 100644 --- a/lib/server-auth.js +++ b/lib/server-auth.js @@ -102,11 +102,20 @@ function attachAuth(ctx) { function getMultiUserFromReq(req) { var cookies = parseCookies(req); var token = cookies[MULTI_USER_COOKIE]; - if (!token) return null; - var userId = multiUserTokens[token]; - if (!userId) return null; - var user = users.findUserById(userId); - return user || null; + if (token) { + var userId = multiUserTokens[token]; + if (userId) { + var user = users.findUserById(userId); + if (user) return user; + } + } + // Solo auto-login: exactly one user with no PIN => frictionless access, no + // login wall. Mirrors the old single-user onramp once single-user mode is + // absorbed into multi-user. Disabled the moment a PIN is set or a second + // user is added (then the normal token/login flow applies). + var solo = users.getSoloNoPinUser(); + if (solo) return solo; + return null; } function isMultiUserAuthed(req) { diff --git a/lib/users.js b/lib/users.js index 43a0cd71..ef22b1ee 100644 --- a/lib/users.js +++ b/lib/users.js @@ -165,6 +165,19 @@ function getAllUsers() { }); } +// Solo frictionless access: exactly one user with no PIN. Returns the full +// user record (callers read id/role/etc.) or null. Used to skip the login wall +// so an absorbed single-user deploy stays zero-friction; auto-disables the +// moment a PIN is set or a second user is added. +function getSoloNoPinUser() { + if (!isMultiUser()) return null; + var data = loadUsers(); + if (!data.users || data.users.length !== 1) return null; + var u = data.users[0]; + if (u.pinHash) return null; + return u; +} + function getOtherUsers(excludeUserId) { return getAllUsers().filter(function (u) { return u.id !== excludeUserId; @@ -451,6 +464,7 @@ module.exports = { findUserByEmail: findUserByEmail, authenticateUser: authenticateUser, getAllUsers: getAllUsers, + getSoloNoPinUser: getSoloNoPinUser, removeUser: removeUser, updateUserProfile: updateUserProfile, updateUserPin: updateUserPin, From 8fd50b81e791ba1ecc497965714f5cdc3ff873ec Mon Sep 17 00:00:00 2001 From: chadbyte Date: Wed, 1 Jul 2026 20:55:14 +1200 Subject: [PATCH 2/4] feat(auth): auto-migrate legacy single-user deploys to multi-user (phase 2) On daemon boot, detect a legacy single-user deploy and fold it into the multi-user model with zero manual steps: - provision one "admin" user that inherits the single-user PIN hash (same PIN still logs in), profile.json, and settings (chatLayout, autoContinue, matesEnabled, terminalFont, deletedBuiltinKeys, mateOnboardingShown) - move legacy flat mates into the per-user mates dir - backfill ownerId onto legacy sessions so they belong to the user - flip multiUser = true Runs before projects/mates/sessions load. Heavily guarded: backs up users.json/daemon.json/profile.json first, is idempotent (config marker), tolerates partial failure per step, and is a no-op on fresh installs and already-multi-user deploys. Combined with the phase-1 solo auto-login, a no-PIN single-user deploy upgrades with no login wall. --- lib/daemon.js | 10 ++ lib/migrate-single-user.js | 237 +++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 lib/migrate-single-user.js diff --git a/lib/daemon.js b/lib/daemon.js index a26d1337..0bf14cb0 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -52,6 +52,16 @@ console.log("[daemon] Users: " + usersModule.USERS_FILE); if (process.env.SUDO_USER) console.log("[daemon] SUDO_USER: " + process.env.SUDO_USER); console.log("[daemon] UID: " + (typeof process.getuid === "function" ? process.getuid() : "N/A")); +// --- Absorb a legacy single-user deploy into the multi-user model (one-time, +// idempotent, best-effort). Runs before projects/mates/sessions load so the +// multi-user flip and on-disk backfill are in effect for the rest of boot. --- +try { + require("./migrate-single-user").migrateSingleUserToMulti(); + config = loadConfig(); // refresh in-memory config to pick up the migration marker +} catch (e) { + console.error("[daemon] single-user migration threw (continuing):", e && e.message); +} + // --- OS users mode: check required system dependencies --- // Supplementary-group inheritance (issue #367): default on so sessions behave // like a normal login; operators can disable via config / the settings toggle. diff --git a/lib/migrate-single-user.js b/lib/migrate-single-user.js new file mode 100644 index 00000000..3c38e16e --- /dev/null +++ b/lib/migrate-single-user.js @@ -0,0 +1,237 @@ +// One-time migration: fold a legacy single-user deploy into the multi-user +// model by auto-provisioning one "admin" user that inherits the single-user +// PIN, profile, and settings. After this, the app is always internally +// multi-user; a solo deploy is just a one-user multi-user deploy (with the +// solo auto-login safety net so there's no login wall when no PIN was set). +// +// Design goals: +// - Seamless: existing users upgrade and keep working with no manual steps. +// Had a PIN -> same PIN still logs in. Had none -> no login wall. +// - Safe: backs up state first, tolerates partial failure (best-effort per +// step), and is a no-op on fresh installs and already-multi-user deploys. +// - Idempotent: a marker in daemon config prevents re-running. +// +// NOTE: this touches on-disk state (users.json, daemon.json, profile.json, +// mates/). It runs once at daemon boot, before the server starts listening. + +var fs = require("fs"); +var path = require("path"); +var config = require("./config"); +var users = require("./users"); + +var SETTING_KEYS = [ + "chatLayout", + "autoContinueOnRateLimit", + "matesEnabled", + "terminalFont", + "deletedBuiltinKeys", + "mateOnboardingShown", +]; + +function backupFile(filePath, stamp) { + try { + if (!fs.existsSync(filePath)) return; + var bak = filePath + ".pre-migrate-" + stamp + ".bak"; + if (fs.existsSync(bak)) return; // don't clobber an existing backup + fs.copyFileSync(filePath, bak); + } catch (e) { + console.error("[migrate] backup failed for " + filePath + ": " + (e.message || e)); + } +} + +// Move legacy flat mates (CONFIG_DIR/mates/*) under the per-user directory +// (CONFIG_DIR/mates//). Best-effort; never fatal. Mates data is +// preserved on disk regardless. +function migrateMatesToUser(userId) { + try { + var matesRoot = path.join(config.CONFIG_DIR, "mates"); + if (!fs.existsSync(matesRoot)) return; + var userDir = path.join(matesRoot, userId); + fs.mkdirSync(userDir, { recursive: true }); + var entries = fs.readdirSync(matesRoot); + for (var i = 0; i < entries.length; i++) { + var name = entries[i]; + // Skip per-user dirs (userId-shaped) and our target dir. + if (name === userId) continue; + // Only move legacy flat artifacts: mates.json and mate_* directories. + var isLegacy = name === "mates.json" || name.indexOf("mate_") === 0; + if (!isLegacy) continue; + var src = path.join(matesRoot, name); + var dst = path.join(userDir, name); + if (fs.existsSync(dst)) continue; // don't clobber freshly-seeded builtins + try { fs.renameSync(src, dst); } + catch (e) { console.error("[migrate] mates move skipped for " + name + ": " + (e.message || e)); } + } + } catch (e) { + console.error("[migrate] mates migration failed (non-fatal): " + (e.message || e)); + } +} + +// Backfill ownerId onto legacy sessions (created before ownership existed) so +// they belong to the migrated user. Walks CONFIG_DIR/sessions/**/*.jsonl and +// patches the first "meta" line in place. Best-effort per file. +function backfillSessionOwners(userId) { + var count = 0; + try { + var root = path.join(config.CONFIG_DIR, "sessions"); + if (!fs.existsSync(root)) return 0; + var stack = [root]; + while (stack.length > 0) { + var dir = stack.pop(); + var entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { continue; } + for (var i = 0; i < entries.length; i++) { + var ent = entries[i]; + var full = path.join(dir, ent.name); + if (ent.isDirectory()) { stack.push(full); continue; } + if (!ent.name.endsWith(".jsonl")) continue; + try { + var content = fs.readFileSync(full, "utf8"); + var nl = content.indexOf("\n"); + var firstLine = nl === -1 ? content : content.slice(0, nl); + var rest = nl === -1 ? "" : content.slice(nl); // includes leading \n + var meta; + try { meta = JSON.parse(firstLine); } catch (e) { continue; } + if (!meta || meta.type !== "meta") continue; + if (meta.ownerId) continue; // already owned + meta.ownerId = userId; + var tmp = full + ".tmp." + Date.now(); + fs.writeFileSync(tmp, JSON.stringify(meta) + rest); + fs.renameSync(tmp, full); + count++; + } catch (e) { /* skip this file */ } + } + } + } catch (e) { + console.error("[migrate] session ownerId backfill failed (non-fatal): " + (e.message || e)); + } + return count; +} + +function readProfile() { + try { + var p = path.join(config.CONFIG_DIR, "profile.json"); + if (!fs.existsSync(p)) return null; + var raw = fs.readFileSync(p, "utf8"); + var obj = JSON.parse(raw); + return (obj && typeof obj === "object") ? obj : null; + } catch (e) { return null; } +} + +// Returns { migrated: true, userId } on success, or { skipped: }. +function migrateSingleUserToMulti() { + try { + // Guard 1: already multi-user -> nothing to do. + if (users.isMultiUser()) return { skipped: "already-multi-user" }; + + // Guard 2: users already exist (partial prior run) -> don't double-create. + var existing = users.getAllUsers(); + if (existing && existing.length > 0) return { skipped: "users-exist" }; + + var cfg = config.loadConfig() || {}; + + // Guard 3: idempotency marker. + if (cfg.singleUserMigratedAt) return { skipped: "already-migrated" }; + + // Guard 4: only migrate deploys that show prior single-user use. A pristine + // fresh install (no PIN, no settings, no profile) is left for the later + // default-flip phase, so this migration is strictly an upgrade path. + var profile = readProfile(); + var hasSettings = SETTING_KEYS.some(function (k) { return cfg[k] !== undefined; }); + var hasPin = !!cfg.pinHash; + if (!profile && !hasSettings && !hasPin) return { skipped: "fresh-install" }; + + var stamp = String(Date.now()); + console.log("[migrate] Legacy single-user deploy detected; migrating to multi-user (stamp=" + stamp + ")"); + + // --- Backup before touching anything --- + backupFile(config.configPath(), stamp); + // users.json / auth-tokens live in CONFIG_DIR; back them up if present. + backupFile(path.join(config.CONFIG_DIR, "users.json"), stamp); + backupFile(path.join(config.CONFIG_DIR, "profile.json"), stamp); + + // --- Flip to multi-user, then provision the sole admin --- + // Flip first so createUser's built-in mate seeding writes to the per-user + // mates path (CONFIG_DIR/mates//), not the flat legacy path. + users.enableMultiUser(); + + var displayName = (profile && profile.name) ? String(profile.name) : "Admin"; + var created = users.createUserWithoutPin({ + username: "admin", + displayName: displayName, + role: "admin", + profile: profile || undefined, + }); + if (!created || !created.user) { + console.error("[migrate] Failed to create admin user: " + (created && created.error)); + // Roll the flag back so we don't leave a userless multi-user deploy. + try { users.disableMultiUser(); } catch (e) {} + return { skipped: "create-failed" }; + } + var userId = created.user.id; + + // --- Carry over the PIN hash directly (preserve the existing credential; + // we have the hash, not the raw PIN, so we can't go through updateUserPin) --- + if (hasPin) { + try { + var data = users.loadUsers(); + for (var i = 0; i < data.users.length; i++) { + if (data.users[i].id === userId) { data.users[i].pinHash = cfg.pinHash; break; } + } + users.saveUsers(data); + } catch (e) { + console.error("[migrate] PIN carry-over failed (user will have no PIN): " + (e.message || e)); + } + } + + // --- Carry over settings --- + try { + if (cfg.chatLayout !== undefined && users.setChatLayout) users.setChatLayout(userId, cfg.chatLayout); + if (cfg.autoContinueOnRateLimit !== undefined && users.setAutoContinue) users.setAutoContinue(userId, cfg.autoContinueOnRateLimit); + if (cfg.matesEnabled !== undefined && users.setMatesEnabled) users.setMatesEnabled(userId, cfg.matesEnabled); + if (cfg.terminalFont && users.setTerminalFont) { + users.setTerminalFont(userId, cfg.terminalFont.family, cfg.terminalFont.size); + } + // Fields without a dedicated setter: write directly. + if (cfg.deletedBuiltinKeys !== undefined || cfg.mateOnboardingShown !== undefined) { + var d2 = users.loadUsers(); + for (var j = 0; j < d2.users.length; j++) { + if (d2.users[j].id === userId) { + if (cfg.deletedBuiltinKeys !== undefined) d2.users[j].deletedBuiltinKeys = cfg.deletedBuiltinKeys; + if (cfg.mateOnboardingShown !== undefined) d2.users[j].mateOnboardingShown = cfg.mateOnboardingShown; + break; + } + } + users.saveUsers(d2); + } + } catch (e) { + console.error("[migrate] settings carry-over failed (non-fatal): " + (e.message || e)); + } + + // --- Move legacy flat mates under the new user --- + migrateMatesToUser(userId); + + // --- Backfill session ownership so legacy sessions belong to the user --- + var backfilled = backfillSessionOwners(userId); + if (backfilled > 0) console.log("[migrate] Backfilled ownerId on " + backfilled + " legacy session(s)"); + + // --- Mark done (keep the old daemon-config keys for now; a later phase + // removes the dual settings path). --- + try { + var freshCfg = config.loadConfig() || {}; + freshCfg.singleUserMigratedAt = Date.now(); + freshCfg.singleUserMigratedUserId = userId; + config.saveConfig(freshCfg); + } catch (e) { + console.error("[migrate] Failed to write migration marker: " + (e.message || e)); + } + + console.log("[migrate] Single-user -> multi-user migration complete. admin userId=" + userId + (hasPin ? " (PIN preserved)" : " (no PIN; solo auto-login)")); + return { migrated: true, userId: userId }; + } catch (e) { + console.error("[migrate] Single-user migration aborted (non-fatal): " + (e.message || e)); + return { skipped: "error", error: e.message || String(e) }; + } +} + +module.exports = { migrateSingleUserToMulti: migrateSingleUserToMulti }; From 58bdae05914b13b7824a69a5395d27c4ff7d63ce Mon Sep 17 00:00:00 2001 From: chadbyte Date: Thu, 2 Jul 2026 21:01:39 +1200 Subject: [PATCH 3/4] feat(auth): default new installs to multi-user, drop the mode prompt (phase 3) There is no separate single-user runtime anymore. Every deploy runs in the (general) multi-user model: - Remove the "Just me (single user) / Multiple users" setup question. Fresh installs go straight to multi-user; OS-user isolation stays an opt-in (offered on Linux at setup, toggleable later). - daemon boot calls ensureMultiUser(): migrate a legacy single-user deploy, or provision a default no-PIN "admin" on a fresh one. With phase-1 solo auto-login, a solo user gets in with no account creation and no login wall. - Flip every mode fallback from "single" to "multi". Solo onramp is unchanged in feel (npx clay-server -> just works); it's now a one-user multi-user deploy under the hood. --- bin/cli.js | 28 ++++++++------------- lib/daemon.js | 11 ++++---- lib/migrate-single-user.js | 51 +++++++++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 24 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index 8e9b1331..7e79004b 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1359,24 +1359,16 @@ function setup(callback) { } port = p; log(sym.bar); - askMode(); + // No "single vs multi" question anymore: every deploy runs in the + // (general) multi-user model. Solo users get an auto-provisioned + // default admin with no PIN and no login wall. OS-level user + // isolation stays an opt-in, offered here on Linux and toggleable + // later; everything else is just multi-user. + askOsUsers("multi"); }); }); } - function askMode() { - promptSelect("How will you use Clay?", [ - { label: "Just me (single user)", value: "single" }, - { label: "Multiple users", value: "multi" }, - ], function (mode) { - if (mode === "single") { - finishSetup(mode, false); - } else { - askOsUsers(mode); - } - }); - } - function askOsUsers(mode) { // Only offer OS user isolation on Linux if (process.platform !== "linux") { @@ -1554,7 +1546,7 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) { keepAwake: keepAwake, dangerouslySkipPermissions: dangerouslySkipPermissions, osUsers: wantOsUsers || osUsersMode, - mode: mode || "single", + mode: mode || "multi", setupCompleted: true, projects: allProjects, }); @@ -1729,7 +1721,7 @@ async function devMode(mode, keepAwake, existingPinHash, wantOsUsers) { debug: true, keepAwake: keepAwake || false, dangerouslySkipPermissions: dangerouslySkipPermissions, - mode: mode || "single", + mode: mode || "multi", setupCompleted: true, projects: allProjects, osUsers: wantOsUsers || (prevDevConfig ? (prevDevConfig.osUsers || false) : false), @@ -2667,7 +2659,7 @@ var currentVersion = require("../package.json").version; }); } else { // Reuse existing config (repeat run) - await devMode(devConfig.mode || "single", devConfig.keepAwake || false, devConfig.pinHash || null, devConfig.osUsers || false); + await devMode(devConfig.mode || "multi", devConfig.keepAwake || false, devConfig.pinHash || null, devConfig.osUsers || false); } return; } @@ -2767,7 +2759,7 @@ var currentVersion = require("../package.json").version; if (isRepeatRun || autoYes) { // Repeat run or --yes: skip wizard, reuse saved config - var savedMode = (savedConfig && savedConfig.mode) || "single"; + var savedMode = (savedConfig && savedConfig.mode) || "multi"; var savedKeepAwake = (savedConfig && savedConfig.keepAwake) || false; var savedOsUsers = (savedConfig && savedConfig.osUsers) || false; diff --git a/lib/daemon.js b/lib/daemon.js index 0bf14cb0..784d30bc 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -52,14 +52,15 @@ console.log("[daemon] Users: " + usersModule.USERS_FILE); if (process.env.SUDO_USER) console.log("[daemon] SUDO_USER: " + process.env.SUDO_USER); console.log("[daemon] UID: " + (typeof process.getuid === "function" ? process.getuid() : "N/A")); -// --- Absorb a legacy single-user deploy into the multi-user model (one-time, -// idempotent, best-effort). Runs before projects/mates/sessions load so the -// multi-user flip and on-disk backfill are in effect for the rest of boot. --- +// --- Ensure the multi-user model (there is no single-user runtime anymore): +// migrate a legacy single-user deploy, or provision a default admin on a fresh +// one. One-time, idempotent, best-effort. Runs before projects/mates/sessions +// load so the flip and on-disk backfill are in effect for the rest of boot. --- try { - require("./migrate-single-user").migrateSingleUserToMulti(); + require("./migrate-single-user").ensureMultiUser(); config = loadConfig(); // refresh in-memory config to pick up the migration marker } catch (e) { - console.error("[daemon] single-user migration threw (continuing):", e && e.message); + console.error("[daemon] ensureMultiUser threw (continuing):", e && e.message); } // --- OS users mode: check required system dependencies --- diff --git a/lib/migrate-single-user.js b/lib/migrate-single-user.js index 3c38e16e..7cd29bff 100644 --- a/lib/migrate-single-user.js +++ b/lib/migrate-single-user.js @@ -234,4 +234,53 @@ function migrateSingleUserToMulti() { } } -module.exports = { migrateSingleUserToMulti: migrateSingleUserToMulti }; +// Guarantee the app is in the multi-user model with at least one user, so the +// single-user runtime never exists. Called at daemon boot: +// - already multi-user with users -> nothing to do +// - legacy single-user with data -> migrate it (preserves PIN/settings) +// - fresh/empty -> provision one no-PIN "admin" +// (solo auto-login: no login wall) +// OS-user isolation stays an orthogonal, settings/flag-driven toggle. +function ensureMultiUser() { + try { + if (users.isMultiUser()) { + var have = users.getAllUsers(); + if (have && have.length > 0) return { skipped: "ready" }; + } + + // Try the legacy single-user migration first (handles PIN/settings/mates/ + // session carry-over for existing deploys). + var mig = migrateSingleUserToMulti(); + if (mig && mig.migrated) return mig; + + // Fresh or empty deploy: provision a default admin so we're multi-user with + // one user. No PIN -> the phase-1 solo auto-login skips the login wall. + var existing = users.getAllUsers(); + if (!existing || existing.length === 0) { + if (!users.isMultiUser()) users.enableMultiUser(); + var created = users.createUserWithoutPin({ + username: "admin", + displayName: "Admin", + role: "admin", + }); + if (!created || !created.user) { + console.error("[migrate] Failed to provision default admin: " + (created && created.error)); + return { skipped: "provision-failed" }; + } + console.log("[migrate] Provisioned default admin (no PIN; solo auto-login). userId=" + created.user.id); + return { provisioned: true, userId: created.user.id }; + } + + // Users exist but flag wasn't set (shouldn't normally happen) -> flip it. + if (!users.isMultiUser()) users.enableMultiUser(); + return { skipped: "flag-fixed" }; + } catch (e) { + console.error("[migrate] ensureMultiUser failed (non-fatal): " + (e.message || e)); + return { skipped: "error", error: e.message || String(e) }; + } +} + +module.exports = { + migrateSingleUserToMulti: migrateSingleUserToMulti, + ensureMultiUser: ensureMultiUser, +}; From a0861cab8939e5ab7989e2e441618ae9dc6b9564 Mon Sep 17 00:00:00 2001 From: chadbyte Date: Thu, 2 Jul 2026 21:49:16 +1200 Subject: [PATCH 4/4] docs: handoff for single-user absorption phases 4-6 Capture the plan, what's done (phases 1-3 + verification), and the remaining cleanup (phase 4 settings dual-path, phase 5 !isMultiUser branch removal, phase 6 flag/dead-code removal) with file:line targets and watch-outs. --- docs/ongoing/SINGLE-USER-ABSORPTION.md | 75 ++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/ongoing/SINGLE-USER-ABSORPTION.md diff --git a/docs/ongoing/SINGLE-USER-ABSORPTION.md b/docs/ongoing/SINGLE-USER-ABSORPTION.md new file mode 100644 index 00000000..63b0f74a --- /dev/null +++ b/docs/ongoing/SINGLE-USER-ABSORPTION.md @@ -0,0 +1,75 @@ +# Single-user → multi-user absorption + +Goal: remove the separate **single-user runtime**. Every deploy runs in the +(general) **multi-user** model; a solo user is just a one-user multi-user +deploy with an auto-provisioned default admin and no login wall. **OS-user +isolation (`config.osUsers`) is orthogonal** and untouched by this work. + +Guiding principle: **migrate-then-flip** — safety nets and data migration land +before the default flips, so existing deploys upgrade with zero manual steps. + +--- + +## Done (this branch) + +| Phase | Commit | What | +|-------|--------|------| +| 1 | `3878977` | Solo auto-login safety net. `getMultiUserFromReq()` falls back to `users.getSoloNoPinUser()` (exactly one user, no PIN) so every HTTP/WS auth gate is satisfied with no login wall. Inert until multi-user. | +| 2 | `8fd50b8` | `lib/migrate-single-user.js` — boot migration of a legacy single-user deploy: carry PIN hash (same PIN still logs in), profile.json, settings (chatLayout, autoContinueOnRateLimit, matesEnabled, terminalFont, deletedBuiltinKeys, mateOnboardingShown), move flat mates → `mates//`, backfill ownerId on legacy sessions, flip `multiUser=true`. Backups + idempotent (config marker) + best-effort per step; no-op on fresh/already-multi. | +| 3 | `58bdae0` | Drop the "Just me / Multiple users" setup prompt; every install is multi-user. Boot calls `ensureMultiUser()`: migrate legacy, or provision a default no-PIN admin on fresh installs. All `mode` fallbacks flipped `"single"` → `"multi"`. | + +Net effect: `isMultiUser()` is effectively always `true` at runtime, and there +is always ≥1 user. Single-user code paths are now **dead** (present but never +executed). + +### Verified (isolated `CLAY_HOME` harness — real `~/.clay` untouched) +- Legacy single-user **with PIN** → migrates; the original PIN authenticates, wrong PIN rejected; profile/settings/session-ownerId/mates all carried; backups + marker written; second run idempotent. +- Legacy single-user **no PIN** → migrates; `pinHash=null` → solo auto-login resolves. +- **Fresh** install → `ensureMultiUser()` provisions a default no-PIN admin; idempotent. + +Repro: +``` +CLAY_HOME=/tmp/clay-migtest/home node /tmp/clay-migtest/run.js # (harness, recreate as needed) +``` +Full E2E (still isolated): boot the daemon against a throwaway home/port: +``` +CLAY_HOME=/tmp/clay-e2e CLAY_PORT=2699 node bin/cli.js +``` + +--- + +## Remaining — cleanup (dead-code removal; do incrementally WITH runtime testing) + +These are safe to defer: the branches are already dead. Value is +maintainability, not behavior. **Do not sweep all 109 branches blindly** — +some single-user branches contain details the multi-user branch lacks (see the +avatar caveat). Remove per-file, runtime-testing each area. + +### Phase 4 — collapse the settings dual path (`dc` daemon-config → user record) +Single-user stored settings in `daemon.json`; multi-user stores them per user. +Now that a user always exists, drop the daemon-config path. +- `lib/server-settings.js`: + - GET `/api/profile` (~L16-75): remove the `else` single-user branch (L37-64) and the `if (users.isMultiUser())` wrapper; always use the `mu` (user-record) path. **Caveat:** the single-user branch has custom-avatar-file logic (L65-74) the `mu` branch lacks — preserve it in the unified path. + - PUT `/api/profile` + the per-setting writes (~L392, L417, L442, L467, L492, L517): remove the `onGetDaemonConfig`/`onSetDaemonConfig` (`dc`) write paths; always write the user record. +- `lib/daemon.js`: `onGetDaemonConfig`/`onSetDaemonConfig` handlers (~L702-783) become unused for these keys once the daemon-config settings path is gone — remove the settings keys from the daemon-config surface. +- Migration keeps the old daemon-config keys on disk (harmless); a later step can strip them. + +### Phase 5 — collapse `!isMultiUser` branches (server, then client) +~109 sites across 27 files (recon map). Categories: +- **AUTH** — `server-auth.js` `getAuthPage`/`isRequestAuthed`, `server.js` ws gate (~L924-974). `isRequestAuthed`/`getAuthPage` can drop the single-user (`pinPage`/daemon-token) branch. +- **SESSION/OWNERSHIP** — `sessions.js` (~L411-419 `getVisibleSessions`, L464 ownerId assign, L773 `_isMySession`), `server.js` notification targeting. Legacy `!s.ownerId` handling can stay as a compat read. +- **UI** — client `app.js` (~L820-839 `/api/me`), `sidebar-projects.js`, `sidebar-sessions.js`, `project-settings.js`, `app-cursors.js`, `app-messages.js` — remove `isMultiUserMode` gates (always on). +- **DM/MATES** — `server-dm.js` (userId `"default"` fallback), `mates.js` `buildMateCtx`/`resolveMatesRoot` (flat path branch). +- **ADMIN** — `server-admin.js` endpoints already require multi-user; no change needed. + +### Phase 6 — remove the flag + dead code +- `users-auth.js` `enableMultiUser`/`disableMultiUser`, `data.multiUser` flag, `isMultiUser()` (make it a constant `true` or remove callers), `defaultData().multiUser`. +- CLI: `disableMultiUser` menu item, single-user status labels (`bin/cli.js` ~L2124), any remaining `mode === "single"` handling. +- Strip migrated settings keys from daemon config. + +--- + +## Watch-outs +- **Solo → real multi-user transition:** the default admin has no PIN. If an operator adds a second user (settings), `getSoloNoPinUser()` stops matching and login is required — but the admin has no PIN and would be locked out. The add-user / enable-auth flow must force the admin to set a PIN first. (Follow-up; not handled yet.) +- **OS mode** stays orthogonal — don't fold it into these phases. +- Migration keeps backups (`*.pre-migrate-.bak`) and a config marker (`singleUserMigratedAt`); safe to re-run.