chore: version packages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 17:35:40 +02:00
parent 2fdf7dabac
commit 6c8d4ca72f
12 changed files with 593 additions and 17 deletions
+95 -1
View File
@@ -9,13 +9,16 @@
* POST /api/admin/users/:username/reset-password
* POST /api/admin/users/:username/logout
* DELETE /api/admin/users/:username
* GET /api/admin/users/:username/cases (list the user's case folders)
* DELETE /api/admin/users/:username/cases/:caseName (delete one case folder)
*
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { readdirSync } from 'node:fs';
import { readdirSync, promises as fsp } from 'node:fs';
import { isAbsolute, join, relative } from 'node:path';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
import {
@@ -24,6 +27,7 @@ import {
deleteUserSpace,
findUser,
generateOneTimePassword,
normalizeUsername,
readUsers,
setPassword,
toPublicUser,
@@ -50,6 +54,10 @@ const UpdateUserSchema = z.object({
});
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
// Case-folder names: a single path segment, no separators, no leading dot (hidden
// dirs like .claude are infrastructure, not cases), so '.'/'..' are excluded too.
const SAFE_CASE_NAME = /^[^./\\][^/\\]{0,127}$/;
/** Map a UserStoreError's code onto the API error code + status. */
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
if (err instanceof UserStoreError) {
@@ -201,4 +209,90 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
return storeError(reply, err);
}
});
// Count live sessions whose workingDir sits inside `dir` (any owner: a folder
// in use by ANYONE must not be deleted out from under a running agent).
const liveSessionsInside = (dir: string): number => {
let n = 0;
for (const s of ctx.sessions.values()) {
const rel = relative(dir, s.workingDir || '');
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) n++;
}
return n;
};
app.get('/api/admin/users/:username/cases', async (req, reply) => {
if (!gate(req, reply)) return;
const username = normalizeUsername((req.params as { username: string }).username);
if (!(await findUser(username))) {
reply.code(404);
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
}
const dir = userCasesDir(username);
let cases: { name: string; modifiedAt: number; liveSessions: number }[] = [];
try {
const entries = await fsp.readdir(dir, { withFileTypes: true });
cases = await Promise.all(
entries
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
.map(async (e) => {
const p = join(dir, e.name);
const st = await fsp.stat(p).catch(() => null);
return { name: e.name, modifiedAt: st ? Math.floor(st.mtimeMs) : 0, liveSessions: liveSessionsInside(p) };
})
);
} catch {
/* no cases dir yet */
}
cases.sort((a, b) => b.modifiedAt - a.modifiedAt);
return { success: true, data: { dir, cases } };
});
app.delete('/api/admin/users/:username/cases/:caseName', async (req, reply) => {
if (!gate(req, reply)) return;
const params = req.params as { username: string; caseName: string };
const username = normalizeUsername(params.username);
if (!(await findUser(username))) {
reply.code(404);
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
}
if (!SAFE_CASE_NAME.test(params.caseName)) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Same guard rails as deleteUserSpace: never follow a symlink, and the
// realpath must stay strictly inside the user's cases dir.
const root = userCasesDir(username);
const target = join(root, params.caseName);
let lst;
try {
lst = await fsp.lstat(target);
} catch {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such case folder');
}
if (lst.isSymbolicLink() || !lst.isDirectory()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Refusing to delete: not a plain directory');
}
const realRoot = await fsp.realpath(root).catch(() => root);
const realTarget = await fsp.realpath(target);
const rel = relative(realRoot, realTarget);
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case folder escapes the user space');
}
const inUse = liveSessionsInside(target) || liveSessionsInside(realTarget);
if (inUse > 0) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Case folder is in use by ${inUse} live session(s), close them first`
);
}
await fsp.rm(realTarget, { recursive: true, force: true });
audit(req, 'user.case-delete', username, { caseName: params.caseName });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { username, caseName: params.caseName } };
});
}