diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 66a073f0..8eb2ff87 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -60,6 +60,7 @@ import { isAdmin, isWorkingDirAllowed, ownerFor, + requireAdmin, resolveCasesDir, SETTINGS_PATH, validatePathWithinBase, @@ -1665,7 +1666,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // ========== Reorder Cases ========== - app.put('/api/cases/order', async (req): Promise> => { + app.put('/api/cases/order', async (req, reply): Promise | undefined> => { + // `caseOrder` is ONE list in the server-wide settings.json, read by every user's + // GET /api/cases, so a non-admin's reorder would replace everyone's order (and + // publish their case names to every user through GET /api/settings). Admin-only in + // multi-user mode, like PUT /api/settings; a per-user order would need per-user + // storage. No-op in single-user mode. + if (isMultiUserMode() && !requireAdmin(req, reply)) return; const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data'); try { diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index ae068b28..e8df0f74 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -16,6 +16,7 @@ import { SseEvent } from '../sse-events.js'; import { autoConfigureRalph, getAuthUser, + isAdmin, ownerFor, resolveCasesDir, sessionCapacityMessage, @@ -463,20 +464,25 @@ export function registerRalphRoutes( }); } - // Save lastUsedCase - try { - let settings: Record = {}; + // Save lastUsedCase. It is a key of the server-wide settings.json, readable by every + // user through GET /api/settings, so a non-admin's case name is not written there in + // multi-user mode (writes are admin-only: PUT /api/settings). isAdmin is always true + // in single-user mode. + if (isAdmin(req)) { try { - settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); + let settings: Record = {}; + try { + settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); + } catch { + /* ignore */ + } + settings.lastUsedCase = caseName; + const dir = dirname(SETTINGS_PATH); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical } catch { - /* ignore */ + /* non-critical */ } - settings.lastUsedCase = caseName; - const dir = dirname(SETTINGS_PATH); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical - } catch { - /* non-critical */ } const sessionId = session.id; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 92529530..d0ebc07e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -4477,28 +4477,33 @@ export function registerSessionRoutes( ).catch(() => {}); } - // Save lastUsedCase to settings for TUI/web sync - try { - const settingsFilePath = SETTINGS_PATH; - let settings: Record = {}; + // Save lastUsedCase to settings for TUI/web sync. It is a key of the server-wide + // settings.json, readable by every user through GET /api/settings, so a non-admin's + // case name is not written there in multi-user mode (writes are admin-only, like + // PUT /api/settings). isAdmin is always true in single-user mode. + if (isAdmin(req)) { try { - settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); + const settingsFilePath = SETTINGS_PATH; + let settings: Record = {}; + try { + settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + settings.lastUsedCase = caseName; + const dir = dirname(settingsFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + // Use async write to avoid blocking event loop + fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => { + // Non-critical but log for debugging + console.warn('[Server] Failed to save settings (lastUsedCase):', err); + }); } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - settings.lastUsedCase = caseName; - const dir = dirname(settingsFilePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - // Use async write to avoid blocking event loop - fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => { // Non-critical but log for debugging - console.warn('[Server] Failed to save settings (lastUsedCase):', err); - }); - } catch (err) { - // Non-critical but log for debugging - console.warn('[Server] Failed to prepare settings update:', err); + console.warn('[Server] Failed to prepare settings update:', err); + } } return { diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 91f49b04..3b9b2ca8 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -869,7 +869,10 @@ export function registerSystemRoutes( return { config: ctx.store.getConfig() }; }); - app.put('/api/config', async (req) => { + app.put('/api/config', async (req, reply) => { + // Process-wide app config in state.json (poll interval, timeouts, the session cap, + // respawn defaults): admin-only in multi-user mode, like PUT /api/settings. + if (isMultiUserMode() && !requireAdmin(req, reply)) return; const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config'); ctx.store.setConfig(configData as Partial>); return { config: ctx.store.getConfig() }; @@ -953,7 +956,13 @@ export function registerSystemRoutes( return readJsonConfig(SETTINGS_PATH, 'settings', {}); }); - app.put('/api/settings', async (req) => { + app.put('/api/settings', async (req, reply) => { + // Multi-user mode: settings.json is ONE file for the whole server (feature switches, + // the plan-usage collection switch, run mode, respawn presets, the tunnel), so a write + // is admin-only, like every other server-wide setting (docs/multi-user-plan.md, + // Settings row). Refused FIRST: nothing is parsed, written, toggled or started for a + // non-admin. GET above stays readable by everyone. No-op in single-user mode. + if (isMultiUserMode() && !requireAdmin(req, reply)) return; const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record; // COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal @@ -1092,7 +1101,10 @@ export function registerSystemRoutes( return { success: true, data: settings.modelConfig || {} }; }); - app.put('/api/execution/model-config', async (req) => { + app.put('/api/execution/model-config', async (req, reply) => { + // The `modelConfig` key of the server-wide settings.json (cron's and the plan + // orchestrator's default model): admin-only in multi-user mode, like PUT /api/settings. + if (isMultiUserMode() && !requireAdmin(req, reply)) return; const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record; try {