remove the dead multi-user surface

Officer is single-user: the server owner is the only account, created once by
/auth/bootstrap. Everything that existed to serve additional users was
unreachable, so it is gone rather than left looking like it does something.

Accounts: drop the invite / resend-invite / delete / list-users routes and the
Users settings screen, the inert /auth/signup handler, and the account
verification chain it fed (verify, resend-verification, VerifyScreen, the
UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token
survives for password resets only, and now requires a reset-password token
rather than accepting any signed JWT.

Roles: drop the users.role column and the four-value USER_ROLES enum. The
permissions table granted every role identical methods, and every
role === 'Super Admin' check was permanently true. The JWT no longer carries a
role claim.

Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected
only for non-Super-Admin users, so it never ran. It was also not a usable agent
jail as written — --share-net, the project root (with .env) bound read-only,
and runuser dropping to the server's own uid. Rebuilding it for agent
containment would be a different construction, and git history keeps this one.

getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the
owner's real login home, which is what terminals, chats and task runs use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
brunorezio
2026-07-25 23:30:20 +01:00
co-authored by Claude Opus 5
parent 92de996412
commit 044aacf4d5
85 changed files with 2761 additions and 2121 deletions
+133 -28
View File
@@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router';
import { resolve, dirname, join, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { getHomeDir, DATA_PATH } from '@@/data-path';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readSttConfig } from '@@/api/server-settings/stt';
@@ -43,16 +43,12 @@ async function syncSeedDir(seedDir: string, targetDir: string) {
}
}
async function seedHomeDir(homeDir: string, isSuperAdmin: boolean) {
async function seedHomeDir(homeDir: string) {
for (const dir of DEFAULT_HOME_DIRS) {
const target = join(homeDir, dir);
if (dir === 'Onboarding') {
if (isSuperAdmin) {
await syncSeedDir(ONBOARDING_ADMIN_SEED, join(homeDir, 'Onboarding'));
await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding', 'User_Onboarding'));
} else {
await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding'));
}
await syncSeedDir(ONBOARDING_ADMIN_SEED, join(homeDir, 'Onboarding'));
await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding', 'User_Onboarding'));
} else if (!existsSync(target)) {
await mkdir(target, { recursive: true });
}
@@ -61,17 +57,14 @@ async function seedHomeDir(homeDir: string, isSuperAdmin: boolean) {
export const router = createRouter();
type UserCtx = { email: string; role: string | null };
type UserCtx = { email: string };
function getUserDataDir(email: string): string {
return join(DATA_PATH, email);
}
function getRootDir(user: UserCtx, root?: string): string {
if (!root || root === 'home') {
if (user.role === 'Super Admin' && process.env.HOME_DIR) return process.env.HOME_DIR;
return getHomeDir(user.email);
}
if (!root || root === 'home') return getOwnerHomeDir(user.email);
if (root === 'user-data') return getUserDataDir(user.email);
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
}
@@ -117,7 +110,24 @@ async function ensureAudioRemux(email: string, absPath: string, relPath: string,
const tmp = `${base}.tmp.${ext}`;
const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : [];
const proc = Bun.spawn(
['ffmpeg', '-v', 'error', '-i', absPath, '-map', '0:v', '-map', `0:a:${track}`, '-c', 'copy', '-dn', '-sn', ...movflags, '-y', tmp],
[
'ffmpeg',
'-v',
'error',
'-i',
absPath,
'-map',
'0:v',
'-map',
`0:a:${track}`,
'-c',
'copy',
'-dn',
'-sn',
...movflags,
'-y',
tmp,
],
{ stdout: 'ignore', stderr: 'pipe' },
);
const code = await proc.exited;
@@ -147,7 +157,7 @@ router.get('/ls', async (ctx) => {
// Auto-create dir if missing (only for user home root)
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
await seedHomeDir(rootDir, user.role === 'Super Admin');
await seedHomeDir(rootDir);
await mkdir(absPath, { recursive: true });
}
@@ -326,7 +336,17 @@ router.get('/raw', async (ctx) => {
});
// List a video's text-based subtitle tracks (for the in-browser player's selector)
const TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'webvtt', 'text', 'subviewer', 'microdvd']);
const TEXT_SUBTITLE_CODECS = new Set([
'subrip',
'srt',
'ass',
'ssa',
'mov_text',
'webvtt',
'text',
'subviewer',
'microdvd',
]);
router.get('/subtitles', async (ctx) => {
const user = ctx.get('user');
@@ -336,7 +356,18 @@ router.get('/subtitles', async (ctx) => {
const absPath = resolveUserPath(rootDir, relPath);
const proc = Bun.spawn(
['ffprobe', '-v', 'error', '-select_streams', 's', '-show_entries', 'stream=codec_name:stream_tags=language,title,handler_name', '-of', 'json', absPath],
[
'ffprobe',
'-v',
'error',
'-select_streams',
's',
'-show_entries',
'stream=codec_name:stream_tags=language,title,handler_name',
'-of',
'json',
absPath,
],
{ stdout: 'pipe', stderr: 'ignore' },
);
const out = await new Response(proc.stdout).text();
@@ -396,13 +427,29 @@ router.get('/audio-tracks', async (ctx) => {
const absPath = resolveUserPath(rootDir, relPath);
const proc = Bun.spawn(
['ffprobe', '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=channels,codec_name,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath],
[
'ffprobe',
'-v',
'error',
'-select_streams',
'a',
'-show_entries',
'stream=channels,codec_name,bit_rate:stream_tags=language,title,handler_name',
'-of',
'json',
absPath,
],
{ stdout: 'pipe', stderr: 'ignore' },
);
const out = await new Response(proc.stdout).text();
await proc.exited;
type ProbeAudio = { channels?: number; codec_name?: string; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string } };
type ProbeAudio = {
channels?: number;
codec_name?: string;
bit_rate?: string;
tags?: { language?: string; title?: string; handler_name?: string };
};
let streams: ProbeAudio[] = [];
try {
streams = (JSON.parse(out).streams as ProbeAudio[]) ?? [];
@@ -427,23 +474,64 @@ router.get('/audio-tracks', async (ctx) => {
// odd files out when they don't. Two files "match" when their audio (language + channel count) and
// subtitle (language) streams line up in order; per-episode titles are ignored (they always differ).
const VIDEO_EXTENSIONS = new Set([
'mp4', 'mkv', 'webm', 'mov', 'avi', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg',
'ts', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v', 'rm', 'rmvb',
'mp4',
'mkv',
'webm',
'mov',
'avi',
'wmv',
'flv',
'm4v',
'mpg',
'mpeg',
'ts',
'm2ts',
'mts',
'3gp',
'ogv',
'vob',
'divx',
'asf',
'f4v',
'rm',
'rmvb',
]);
type FolderAudioTrack = { id: number; codec: string; channels: number; bitrate: number | null; lang: string; title: string };
type FolderAudioTrack = {
id: number;
codec: string;
channels: number;
bitrate: number | null;
lang: string;
title: string;
};
type FolderSubtitleTrack = { id: number; codec: string; lang: string; title: string };
type ProbedTracks = { audio: FolderAudioTrack[]; subtitle: FolderSubtitleTrack[] };
async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
const proc = Bun.spawn(
['ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,codec_name,channels,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath],
[
'ffprobe',
'-v',
'error',
'-show_entries',
'stream=codec_type,codec_name,channels,bit_rate:stream_tags=language,title,handler_name',
'-of',
'json',
absPath,
],
{ stdout: 'pipe', stderr: 'ignore' },
);
const out = await new Response(proc.stdout).text();
await proc.exited;
type ProbeStream = { codec_type?: string; codec_name?: string; channels?: number; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string } };
type ProbeStream = {
codec_type?: string;
codec_name?: string;
channels?: number;
bit_rate?: string;
tags?: { language?: string; title?: string; handler_name?: string };
};
let streams: ProbeStream[] = [];
try {
streams = (JSON.parse(out).streams as ProbeStream[]) ?? [];
@@ -453,7 +541,14 @@ async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
const audio = streams
.filter((s) => s.codec_type === 'audio')
.map((s, id) => ({ id, codec: s.codec_name ?? '', channels: s.channels ?? 0, bitrate: kbps(s.bit_rate), lang: s.tags?.language ?? '', title: trackName(s.tags) }));
.map((s, id) => ({
id,
codec: s.codec_name ?? '',
channels: s.channels ?? 0,
bitrate: kbps(s.bit_rate),
lang: s.tags?.language ?? '',
title: trackName(s.tags),
}));
// subtitle `id` is the index among ALL subtitle streams (what `-map 0:s:id` expects), assigned
// before filtering out image-based tracks that can't become soft subs.
const subtitle = streams
@@ -490,12 +585,20 @@ router.get('/probe-folder', async (ctx) => {
for (let i = 0; i < files.length; i += CONCURRENCY) {
const batch = files.slice(i, i + CONCURRENCY);
const results = await Promise.all(
batch.map(async (file) => ({ file, tracks: await probeVideoTracks(resolveUserPath(rootDir, join(relPath, file))) })),
batch.map(async (file) => ({
file,
tracks: await probeVideoTracks(resolveUserPath(rootDir, join(relPath, file))),
})),
);
probed.push(...results);
}
type Group = { signature: string; files: string[]; audioTracks: FolderAudioTrack[]; subtitleTracks: FolderSubtitleTrack[] };
type Group = {
signature: string;
files: string[];
audioTracks: FolderAudioTrack[];
subtitleTracks: FolderSubtitleTrack[];
};
const groupsMap = new Map<string, Group>();
for (const { file, tracks } of probed) {
const sig = layoutSignature(tracks);
@@ -1161,7 +1264,9 @@ async function runReclipDownload(jobId: string, url: string, absPath: string, au
for (;;) {
if (Date.now() > deadline) throw new Error('Download timed out');
await new Promise((r) => setTimeout(r, 2000));
const stRes = await fetch(`${RECLIP_BASE}/api/status/${reclipJob}`, { signal: AbortSignal.timeout(15_000) }).catch(() => null);
const stRes = await fetch(`${RECLIP_BASE}/api/status/${reclipJob}`, {
signal: AbortSignal.timeout(15_000),
}).catch(() => null);
if (!stRes?.ok) continue;
const st = (await stRes.json()) as { status: string; error?: string | null; filename?: string | null };
if (st.status === 'error') throw new Error(st.error || 'ReClip download failed');