maestro/src/bridge/user-folder-api.ts
oss-sync 9f8958c4a2
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (ce93095)
2026-06-10 03:52:37 +00:00

980 lines
36 KiB
TypeScript

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<typeof statSync>;
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 (<folder>/<file.md>)
const segments = relPath.split('/').filter(s => s.length > 0);
if (segments.length !== 2) {
res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
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 <folder>/<file.md>' });
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<typeof compileScript>;
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<string, unknown>) ?? {}) as {
params?: Record<string, unknown>;
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=<id> ───────────────────────────────────
// 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;
}