import { Router, type Request, type Response, type NextFunction } from 'express'; import express from 'express'; import { existsSync, readFileSync, writeFileSync, statSync, readdirSync, renameSync, mkdirSync, unlinkSync } from 'fs'; import { join, dirname, basename } from 'path'; import { USER_SUBDIRS, type UserSubdir, ensureUserFolder, resolveUserSubdir, userRoot, readUserAgentsMd, writeUserAgentsMd, deleteUserAgentsMd, } from '../user-folder/paths.js'; import { logger } from '../logger.js'; import { compileScript } from '../user-folder/script-compiler.js'; import { parseScript, serializeScript } from '../user-folder/frontmatter.js'; import { runUserScript } from '../user-folder/script-runner.js'; import type { RecordedAction } from '../engine/browser-recorder.js'; import { recorder } from '../engine/browser-recorder.js'; import type { BrowserSessionRepo } from '../db/browser-session-repo.js'; import { loadSessionStateForUser } from '../user-folder/session-loader.js'; import { deletePet, getPet, importPetZip, listPets, PetConflictError, PetValidationError, readPetSettings, resolvePetAsset, slugifyPetId, writePetSettings, } from '../user-folder/pets.js'; import type { NotesService } from '../notes/notes-service.js'; interface Deps { userFolderRoot: string; sessRepo?: BrowserSessionRepo; masterKeyPath?: string; authActive?: boolean; // default true; when false, fall back to synthetic 'local' user notesService?: NotesService; } interface AuthedUser { id: string; role: string; } function getUser(req: Request): AuthedUser | null { return (req.user as AuthedUser | undefined) ?? null; } const MAX_FILE_BYTES = 1024 * 1024; // 1 MB function isUserSubdir(s: string): s is UserSubdir { return (USER_SUBDIRS as readonly string[]).includes(s); } // Subdirs that users may write to / delete from. 'trash' is system-managed. // 'notes' is included here so the PUT/DELETE whitelist accepts it; those handlers // then delegate immediately to NotesService rather than the generic file writer. const WRITABLE_SUBDIRS = ['browser-macros', 'recordings', 'notes'] as const; type WritableSubdir = typeof WRITABLE_SUBDIRS[number]; function isWritableSubdir(s: string): s is WritableSubdir { return (WRITABLE_SUBDIRS as readonly string[]).includes(s); } /** * Write file atomically via tmp + rename. * The tmp file is created in the same directory as the target to ensure * rename is an atomic single-filesystem move. */ function writeAtomic(path: string, content: string): void { const dir = dirname(path); mkdirSync(dir, { recursive: true }); const tmp = join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`); let renamed = false; try { writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 }); renameSync(tmp, path); renamed = true; } finally { if (!renamed) { try { unlinkSync(tmp); } catch { /* tmp may not exist if writeFileSync threw */ } } } } /** * Format a Date as YYYYMMDD-HHMMSS in UTC (used for trash prefix). */ function utcTimestamp(d: Date): string { const pad = (n: number, len = 2) => String(n).padStart(len, '0'); return ( `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + `-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}` ); } export function createUserFolderApi(deps: Deps): Router { const { userFolderRoot } = deps; const r = Router(); // ── Auth gate ──────────────────────────────────────────────────────────── const authActive = deps.authActive ?? true; r.use((req: Request, res: Response, next) => { if (!authActive && !getUser(req)) { // Local-dev / no-auth mode: inject a synthetic 'local' user so handlers // can operate against data/users/local/. Real OAuth deployments are // unaffected because authActive=true and Passport populates req.user. (req as any).user = { id: 'local', role: 'user' }; } if (!getUser(req)) { res.status(401).json({ error: 'Unauthenticated' }); return; } next(); }); // ── Pets: Codex Pets-compatible user imports ──────────────────────────── r.get('/pets', (req: Request, res: Response) => { const u = getUser(req)!; try { res.json({ pets: listPets(userFolderRoot, u.id), settings: readPetSettings(userFolderRoot, u.id), }); } catch (err) { logger.error(`[user-folder-api] pets list failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to list pets' }); } }); r.post('/pets/import', express.raw({ limit: '12mb', type: '*/*' }), (req: Request, res: Response) => { const u = getUser(req)!; const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); const rawPetId = typeof req.query['petId'] === 'string' ? req.query['petId'] : typeof req.query['filename'] === 'string' ? req.query['filename'] : null; const overwrite = req.query['overwrite'] === 'true'; try { const detail = importPetZip(userFolderRoot, u.id, body, { preferredId: rawPetId ? slugifyPetId(rawPetId) : null, overwrite, }); res.json({ ok: true, pet: detail }); } catch (err) { if (err instanceof PetConflictError) { res.status(409).json({ error: err.message, petId: err.petId }); return; } if (err instanceof PetValidationError || err instanceof SyntaxError) { res.status(400).json({ error: (err as Error).message }); return; } logger.error(`[user-folder-api] pet import failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to import pet' }); } }); r.get('/pets/settings', (req: Request, res: Response) => { const u = getUser(req)!; try { res.json({ settings: readPetSettings(userFolderRoot, u.id) }); } catch (err) { logger.error(`[user-folder-api] pet settings read failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to read pet settings' }); } }); r.put('/pets/settings', express.json({ limit: '32kb' }), (req: Request, res: Response) => { const u = getUser(req)!; try { const settings = writePetSettings(userFolderRoot, u.id, req.body); res.json({ ok: true, settings }); } catch (err) { if (err instanceof PetValidationError) { res.status(400).json({ error: err.message }); return; } logger.error(`[user-folder-api] pet settings write failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to write pet settings' }); } }); r.get('/pets/:petId/assets/:file', (req: Request, res: Response) => { const u = getUser(req)!; const asset = resolvePetAsset(userFolderRoot, u.id, req.params.petId, req.params.file); if (!asset) { res.status(404).json({ error: 'Asset not found' }); return; } res.setHeader('Content-Type', asset.contentType); res.sendFile(asset.path); }); r.get('/pets/:petId', (req: Request, res: Response) => { const u = getUser(req)!; try { const pet = getPet(userFolderRoot, u.id, req.params.petId); if (!pet) { res.status(404).json({ error: 'Pet not found' }); return; } res.json({ pet }); } catch (err) { if (err instanceof PetValidationError) { res.status(400).json({ error: err.message }); return; } logger.error(`[user-folder-api] pet read failed user=${u.id} pet=${req.params.petId} err=${err}`); res.status(500).json({ error: 'Failed to read pet' }); } }); r.delete('/pets/:petId', (req: Request, res: Response) => { const u = getUser(req)!; try { const deleted = deletePet(userFolderRoot, u.id, req.params.petId); if (!deleted) { res.status(404).json({ error: 'Pet not found' }); return; } res.json({ ok: true }); } catch (err) { logger.error(`[user-folder-api] pet delete failed user=${u.id} pet=${req.params.petId} err=${err}`); res.status(500).json({ error: 'Failed to delete pet' }); } }); // ── GET /folder/list?subdir=scripts ────────────────────────────────────── r.get('/folder/list', (req: Request, res: Response) => { const u = getUser(req)!; const subdir = req.query['subdir'] as string | undefined; if (!subdir || !isUserSubdir(subdir)) { res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` }); return; } try { ensureUserFolder(userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to ensure user folder' }); return; } const dirPath = join(userRoot(userFolderRoot, u.id), subdir); try { const entries = readdirSync(dirPath, { withFileTypes: true }); const files = entries .filter(e => e.isFile() && !e.name.startsWith('.')) .map(e => { const stat = statSync(join(dirPath, e.name)); return { name: e.name, size: stat.size, mtime: stat.mtime.toISOString(), }; }); res.json({ files }); } catch (err) { logger.error(`[user-folder-api] list failed user=${u.id} subdir=${subdir} err=${err}`); res.status(500).json({ error: 'Failed to list folder' }); } }); // ── GET /folder/file?subdir=scripts&path=foo.js ────────────────────────── r.get('/folder/file', (req: Request, res: Response) => { const u = getUser(req)!; const subdir = req.query['subdir'] as string | undefined; const relPath = req.query['path'] as string | undefined; if (!subdir || !isUserSubdir(subdir)) { res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` }); return; } if (!relPath) { res.status(400).json({ error: 'path query parameter is required' }); return; } try { ensureUserFolder(userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to ensure user folder' }); return; } let fullPath: string; try { fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath); } catch { res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' }); return; } if (!existsSync(fullPath)) { res.status(404).json({ error: 'File not found' }); return; } let stat: ReturnType; try { stat = statSync(fullPath); } catch { res.status(404).json({ error: 'File not found' }); return; } if (!stat.isFile()) { res.status(404).json({ error: 'Not a file' }); return; } if (stat.size > MAX_FILE_BYTES) { res.status(413).json({ error: 'File exceeds 1 MB limit' }); return; } try { const content = readFileSync(fullPath, 'utf-8'); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.send(content); } catch (err) { logger.error(`[user-folder-api] read failed user=${u.id} path=${relPath} err=${err}`); res.status(500).json({ error: 'Failed to read file' }); } }); // ── PUT /folder/file?subdir=scripts&path=foo.js ────────────────────────── r.put('/folder/file', express.text({ limit: '1mb', type: '*/*' }), async (req: Request, res: Response) => { const u = getUser(req)!; const subdir = req.query['subdir'] as string | undefined; const relPath = req.query['path'] as string | undefined; if (!subdir || !isWritableSubdir(subdir)) { res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` }); return; } if (!relPath) { res.status(400).json({ error: 'path query parameter is required' }); return; } // ── notes subdir: delegate entirely to NotesService ────────────────── if (subdir === 'notes') { if (!deps.notesService) { res.status(500).json({ error: 'notesService is not configured; cannot write notes' }); return; } // Validate path: must be exactly 2 segments (/) const segments = relPath.split('/').filter(s => s.length > 0); if (segments.length !== 2) { res.status(400).json({ error: 'notes path must be exactly /' }); return; } const [folder, fileName] = segments as [string, string]; const content = typeof req.body === 'string' ? req.body : ''; try { deps.notesService.writeNote({ ownerId: u.id, folder, fileName, content }); res.json({ ok: true, indexed: true }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (/scope_org_id|invalid|path|\.md/.test(msg)) { res.status(400).json({ error: msg }); return; } logger.error(`[user-folder-api] notes write failed user=${u.id} path=${relPath} err=${err}`); res.status(500).json({ error: 'Failed to write note' }); } return; } try { ensureUserFolder(userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to ensure user folder' }); return; } let fullPath: string; try { fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath); } catch { res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' }); return; } const content = typeof req.body === 'string' ? req.body : ''; try { writeAtomic(fullPath, content); const stat = statSync(fullPath); res.json({ ok: true, size: stat.size, mtime: stat.mtime.toISOString() }); } catch (err) { logger.error(`[user-folder-api] write failed user=${u.id} path=${relPath} err=${err}`); res.status(500).json({ error: 'Failed to write file' }); } }); // ── DELETE /folder/file?subdir=scripts&path=foo.js ─────────────────────── r.delete('/folder/file', async (req: Request, res: Response) => { const u = getUser(req)!; const subdir = req.query['subdir'] as string | undefined; const relPath = req.query['path'] as string | undefined; if (!subdir || !isWritableSubdir(subdir)) { res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` }); return; } if (!relPath) { res.status(400).json({ error: 'path query parameter is required' }); return; } // ── notes subdir: delegate entirely to NotesService ────────────────── if (subdir === 'notes') { if (!deps.notesService) { res.status(500).json({ error: 'notesService is not configured; cannot delete notes' }); return; } const segments = relPath.split('/').filter(s => s.length > 0); if (segments.length !== 2) { res.status(400).json({ error: 'notes path must be exactly /' }); return; } const [folder, fileName] = segments as [string, string]; try { deps.notesService.deleteNote({ ownerId: u.id, folder, fileName }); res.json({ ok: true }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (/scope_org_id|invalid|path|\.md/.test(msg)) { res.status(400).json({ error: msg }); return; } logger.error(`[user-folder-api] notes delete failed user=${u.id} path=${relPath} err=${err}`); res.status(500).json({ error: 'Failed to delete note' }); } return; } try { ensureUserFolder(userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to ensure user folder' }); return; } let fullPath: string; try { fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath); } catch { res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' }); return; } if (!existsSync(fullPath)) { res.status(404).json({ error: 'File not found' }); return; } // Extract the base filename for the trash name const originalName = relPath.split('/').pop()!; const ts = utcTimestamp(new Date()); const suffix = Math.random().toString(16).slice(2, 6); const trashedAs = `${ts}-${suffix}-${originalName}`; const trashDir = join(userRoot(userFolderRoot, u.id), 'trash'); const trashPath = join(trashDir, trashedAs); try { renameSync(fullPath, trashPath); res.json({ ok: true, trashedAs }); } catch (err) { logger.error(`[user-folder-api] delete/trash failed user=${u.id} path=${relPath} err=${err}`); res.status(500).json({ error: 'Failed to move file to trash' }); } }); // ── POST /browser-macros/compile ────────────────────────────────────────────── r.post('/browser-macros/compile', express.json({ limit: '256kb' }), async (req: Request, res: Response) => { const u = getUser(req)!; const { recordingName, scriptName, description, sessionProfileId, paramHints, } = req.body as { recordingName?: unknown; scriptName?: unknown; description?: unknown; sessionProfileId?: unknown; paramHints?: unknown; }; if (typeof recordingName !== 'string' || !recordingName.trim()) { res.status(400).json({ error: 'recordingName is required' }); return; } if (typeof scriptName !== 'string' || !scriptName.trim()) { res.status(400).json({ error: 'scriptName is required' }); return; } if (typeof description !== 'string') { res.status(400).json({ error: 'description is required' }); return; } try { ensureUserFolder(userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to ensure user folder' }); return; } // Resolve recording path let recordingPath: string; try { recordingPath = resolveUserSubdir(userFolderRoot, u.id, 'recordings', `${recordingName}.json`); } catch { res.status(400).json({ error: 'Invalid recordingName' }); return; } if (!existsSync(recordingPath)) { res.status(404).json({ error: `Recording not found: ${recordingName}.json` }); return; } // Parse recording JSON let recording: { recordTo?: unknown; capturedAt?: unknown; actions?: unknown }; try { recording = JSON.parse(readFileSync(recordingPath, 'utf-8')) as typeof recording; } catch { res.status(400).json({ error: 'Recording is not valid JSON' }); return; } // Validate shape if (!recording || typeof recording !== 'object' || !Array.isArray(recording.actions)) { res.status(400).json({ error: 'Recording is missing required fields (expected { recordTo, capturedAt, actions })' }); return; } // Conflict policy: check if script already exists const scriptFileName = scriptName.endsWith('.js') ? scriptName : `${scriptName}.js`; let scriptPath: string; try { scriptPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName); } catch { res.status(400).json({ error: 'Invalid scriptName' }); return; } if (existsSync(scriptPath) && req.query['overwrite'] !== 'true') { res.status(409).json({ error: 'Script already exists; pass ?overwrite=true to replace' }); return; } // Validate paramHints shape if (paramHints !== undefined) { if (!Array.isArray(paramHints)) { res.status(400).json({ error: 'paramHints must be an array' }); return; } for (let i = 0; i < paramHints.length; i++) { const hint = paramHints[i]; if (!hint || typeof hint !== 'object' || typeof hint.name !== 'string' || !hint.name || typeof hint.valueToReplace !== 'string' || !['string', 'number', 'boolean'].includes((hint as any).type)) { res.status(400).json({ error: `paramHints[${i}] must be { name: string, valueToReplace: string, type: 'string' | 'number' | 'boolean' }`, }); return; } } } // Compile let compiled: ReturnType; try { compiled = compileScript({ recording: recording.actions as RecordedAction[], description, sessionProfileId: typeof sessionProfileId === 'number' ? sessionProfileId : undefined, paramHints: Array.isArray(paramHints) ? (paramHints as { name: string; valueToReplace: string; type: 'string' | 'number' | 'boolean' }[]) : undefined, recordingSource: `${recordingName}.json`, }); } catch (err) { logger.error(`[user-folder-api] compile failed user=${u.id} recording=${recordingName} err=${err}`); res.status(500).json({ error: `Compile failed: ${(err as Error).message}` }); return; } // Stamp timestamps and re-serialize const now = new Date().toISOString(); const meta = { ...compiled.meta, createdAt: now, updatedAt: now }; const { body } = parseScript(compiled.source); const source = serializeScript({ frontmatter: meta, body }); // Write atomically try { writeAtomic(scriptPath, source); } catch (err) { logger.error(`[user-folder-api] write failed user=${u.id} script=${scriptName} err=${err}`); res.status(500).json({ error: 'Failed to write script file' }); return; } const size = statSync(scriptPath).size; res.json({ ok: true, scriptName: scriptFileName, source, size }); }); // ── POST /scripts/:name/run ─────────────────────────────────────────────────── // body.kind: 'script' | 'browser-macro' — determines which subdir to load from. // If omitted, both are tried (scripts/ first, then browser-macros/). r.post('/scripts/:name/run', express.json({ limit: '256kb' }), async (req: Request, res: Response) => { const u = getUser(req)!; const rawName = req.params['name'] ?? ''; const scriptFileName = rawName.endsWith('.js') ? rawName : `${rawName}.js`; try { ensureUserFolder(userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to ensure user folder' }); return; } const { params, timeoutMs } = ((req.body as Record) ?? {}) as { params?: Record; timeoutMs?: number; }; // Plain-Node scripts/ were retired (2026-06) — only browser-macros run here. let scriptPath: string | null = null; const resolvedRuntime = 'playwright' as const; try { const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName); if (existsSync(candidate)) scriptPath = candidate; } catch { /* invalid path */ } if (scriptPath === null) { res.status(404).json({ error: `Script not found: ${scriptFileName}` }); return; } // Clamp timeoutMs to 5 minutes max (prevent malicious long-running requests) const requestedTimeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 60_000; const cappedTimeout = Math.min(requestedTimeout, 300_000); // Load session storageState if it's a playwright-runtime script with sessionProfileId let storageState: object | undefined; if (resolvedRuntime === 'playwright') { try { const source = readFileSync(scriptPath, 'utf-8'); const parsed = parseScript(source); const sessionProfileId = parsed.frontmatter.sessionProfileId; if (sessionProfileId !== undefined) { if (!deps.sessRepo || !deps.masterKeyPath) { res.status(500).json({ error: 'Session profile required but session repository is not configured', }); return; } const sessionResult = await loadSessionStateForUser( { sessRepo: deps.sessRepo, masterKeyPath: deps.masterKeyPath }, u.id, sessionProfileId, ); if (!sessionResult.ok) { res.status(500).json({ error: sessionResult.error.message }); return; } storageState = sessionResult.storageState; } } catch (err) { res.status(500).json({ error: `Failed to parse script: ${(err as Error).message}` }); return; } } // Run const startMs = Date.now(); try { const scriptResult = await runUserScript({ scriptPath, params: params ?? {}, runtime: resolvedRuntime, storageState, timeoutMs: cappedTimeout, }); const durationMs = Date.now() - startMs; res.json({ result: scriptResult.result, logs: scriptResult.logs, durationMs }); } catch (err) { const durationMs = Date.now() - startMs; const message = (err as Error).message; res.status(500).json({ error: message, durationMs }); } }); // ── GET /browser-macros/:name/diff ─────────────────────────────────────────── // Returns { current: string|null, candidate: string, candidateMtime: string } // or 404 if no .next.js exists. r.get('/browser-macros/:name/diff', (req: Request, res: Response) => { const u = getUser(req)!; const rawName = req.params['name'] ?? ''; // Normalize: strip any trailing .js suffix to get the bare name const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName; try { ensureUserFolder(userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to ensure user folder' }); return; } let candidatePath: string; let currentPath: string; try { candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`); currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`); } catch { res.status(400).json({ error: 'Invalid script name' }); return; } if (!existsSync(candidatePath)) { res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` }); return; } let candidate: string; let candidateMtime: string; try { candidate = readFileSync(candidatePath, 'utf-8'); candidateMtime = statSync(candidatePath).mtime.toISOString(); } catch (err) { logger.error(`[user-folder-api] diff read candidate failed user=${u.id} name=${baseName} err=${err}`); res.status(500).json({ error: 'Failed to read candidate file' }); return; } // current may not exist (orphaned .next.js) let current: string | null = null; if (existsSync(currentPath)) { try { current = readFileSync(currentPath, 'utf-8'); } catch (err) { logger.error(`[user-folder-api] diff read current failed user=${u.id} name=${baseName} err=${err}`); res.status(500).json({ error: 'Failed to read current file' }); return; } } res.json({ current, candidate, candidateMtime }); }); // ── POST /browser-macros/:name/accept ──────────────────────────────────────── // Atomically archives browser-macros/{name}.js to trash, then renames .next.js into place. // NOTE: Not fully atomic — a crash between step 1 and step 2 would leave no // browser-macros/{name}.js. Acceptable given the complexity of a copy-then-rename // alternative. The .next.js is always preserved or moved to trash. r.post('/browser-macros/:name/accept', (req: Request, res: Response) => { const u = getUser(req)!; const rawName = req.params['name'] ?? ''; const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName; try { ensureUserFolder(userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to ensure user folder' }); return; } let candidatePath: string; let currentPath: string; try { candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`); currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`); } catch { res.status(400).json({ error: 'Invalid script name' }); return; } if (!existsSync(candidatePath)) { res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` }); return; } const trashDir = join(userRoot(userFolderRoot, u.id), 'trash'); const ts = utcTimestamp(new Date()); const suffix = Math.random().toString(16).slice(2, 6); let archivedAs: string | null = null; try { // Step 1: Archive the existing script to trash (if it exists) if (existsSync(currentPath)) { archivedAs = `${ts}-${suffix}-${baseName}.js`; const trashPath = join(trashDir, archivedAs); renameSync(currentPath, trashPath); } // Step 2: Rename .next.js into the canonical script location renameSync(candidatePath, currentPath); } catch (err) { logger.error(`[user-folder-api] accept failed user=${u.id} name=${baseName} err=${err}`); res.status(500).json({ error: 'Failed to accept patch' }); return; } logger.info(`[user-folder-api] accept user=${u.id} name=${baseName} archivedAs=${archivedAs ?? 'none'}`); res.json({ ok: true, accepted: `${baseName}.js`, archivedAs }); }); // ── POST /browser-macros/:name/reject ──────────────────────────────────────── // Moves browser-macros/{name}.next.js to trash; the original .js is untouched. r.post('/browser-macros/:name/reject', (req: Request, res: Response) => { const u = getUser(req)!; const rawName = req.params['name'] ?? ''; const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName; try { ensureUserFolder(userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to ensure user folder' }); return; } let candidatePath: string; try { candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`); } catch { res.status(400).json({ error: 'Invalid script name' }); return; } if (!existsSync(candidatePath)) { res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` }); return; } const trashDir = join(userRoot(userFolderRoot, u.id), 'trash'); const ts = utcTimestamp(new Date()); const suffix = Math.random().toString(16).slice(2, 6); const trashedAs = `${ts}-${suffix}-${baseName}.next.js`; const trashPath = join(trashDir, trashedAs); try { renameSync(candidatePath, trashPath); } catch (err) { logger.error(`[user-folder-api] reject failed user=${u.id} name=${baseName} err=${err}`); res.status(500).json({ error: 'Failed to reject patch' }); return; } logger.info(`[user-folder-api] reject user=${u.id} name=${baseName} trashedAs=${trashedAs}`); res.json({ ok: true, rejected: `${baseName}.next.js`, trashedAs }); }); // ── POST /recordings/flush?taskId= ─────────────────────────────────── // Flush the in-memory recording buffer for a given taskId to disk. // Returns { ok: true, recordingName, path } or 404 if no buffer exists. r.post('/recordings/flush', (req: Request, res: Response) => { const u = getUser(req)!; const taskId = req.query['taskId'] as string | undefined; if (!taskId) { res.status(400).json({ error: 'taskId query parameter is required' }); return; } let absPath: string | null; try { absPath = recorder.flush(taskId, userFolderRoot, u.id); } catch (err) { logger.error(`[user-folder-api] recordings/flush failed user=${u.id} taskId=${taskId} err=${err}`); res.status(500).json({ error: 'Failed to flush recording' }); return; } if (absPath === null) { res.status(404).json({ error: 'no active recording for this task' }); return; } // e.g. absPath = "/data/users/user-a/recordings/my-rec.json" // recordingName = "my-rec" (basename without .json) const fileBasename = basename(absPath); const recordingName = fileBasename.endsWith('.json') ? fileBasename.slice(0, -5) : fileBasename; const relPath = `recordings/${fileBasename}`; logger.info(`[user-folder-api] recordings/flush user=${u.id} taskId=${taskId} recordingName=${recordingName}`); res.json({ ok: true, recordingName, path: relPath }); }); // ── GET /agents-md ─────────────────────────────────────────────────────── r.get('/agents-md', (req: Request, res: Response) => { const u = getUser(req)!; try { const content = readUserAgentsMd(userFolderRoot, u.id); if (content === null) { res.json({ exists: false, content: '' }); return; } res.json({ exists: true, content }); } catch (err) { logger.error(`[user-folder-api] read AGENTS.md failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to read AGENTS.md' }); } }); // ── PUT /agents-md ─────────────────────────────────────────────────────── r.put('/agents-md', express.text({ type: '*/*', limit: '256kb' }), (req: Request, res: Response) => { const u = getUser(req)!; const body = (req.body as unknown) as string; if (typeof body !== 'string') { res.status(400).json({ error: 'body must be text/plain' }); return; } try { writeUserAgentsMd(userFolderRoot, u.id, body); res.json({ ok: true, bytes: Buffer.byteLength(body, 'utf-8') }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('exceeds')) { res.status(413).json({ error: msg }); return; } logger.error(`[user-folder-api] write AGENTS.md failed user=${u.id} err=${msg}`); res.status(500).json({ error: 'Failed to write AGENTS.md' }); } }); // ── DELETE /agents-md ──────────────────────────────────────────────────── r.delete('/agents-md', (req: Request, res: Response) => { const u = getUser(req)!; try { const existed = deleteUserAgentsMd(userFolderRoot, u.id); res.json({ ok: true, existed }); } catch (err) { logger.error(`[user-folder-api] delete AGENTS.md failed user=${u.id} err=${err}`); res.status(500).json({ error: 'Failed to delete AGENTS.md' }); } }); // ── Router-level error middleware ───────────────────────────────────────── // Catches errors from route handlers (e.g. express.text body-too-large). r.use((err: any, _req: Request, res: Response, _next: NextFunction) => { if (err && err.type === 'entity.too.large') { res.status(413).json({ error: 'Request body exceeds 1 MB limit' }); return; } res.status(err?.status ?? 500).json({ error: err?.message ?? 'Internal error' }); }); return r; }