adding more security to api endpoints to require api token for all access, no by passing

This commit is contained in:
Test User
2025-12-29 16:16:28 -05:00
parent dd822c41c5
commit d68de99c15
26 changed files with 1347 additions and 184 deletions

View File

@@ -29,6 +29,7 @@
"@automaker/types": "^1.0.0", "@automaker/types": "^1.0.0",
"@automaker/utils": "^1.0.0", "@automaker/utils": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.1", "@modelcontextprotocol/sdk": "^1.25.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
@@ -37,6 +38,7 @@
"ws": "^8.18.3" "ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/morgan": "^1.9.10", "@types/morgan": "^1.9.10",

View File

@@ -9,15 +9,22 @@
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import morgan from 'morgan'; import morgan from 'morgan';
import cookieParser from 'cookie-parser';
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http'; import { createServer } from 'http';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { createEventEmitter, type EventEmitter } from './lib/events.js'; import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths } from '@automaker/platform'; import { initAllowedPaths } from '@automaker/platform';
import { authMiddleware, getAuthStatus } from './lib/auth.js'; import {
authMiddleware,
validateSession,
validateApiKey,
getSessionCookieName,
} from './lib/auth.js';
import { createAuthRoutes } from './routes/auth/index.js';
import { createFsRoutes } from './routes/fs/index.js'; import { createFsRoutes } from './routes/fs/index.js';
import { createHealthRoutes } from './routes/health/index.js'; import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
import { createAgentRoutes } from './routes/agent/index.js'; import { createAgentRoutes } from './routes/agent/index.js';
import { createSessionsRoutes } from './routes/sessions/index.js'; import { createSessionsRoutes } from './routes/sessions/index.js';
import { createFeaturesRoutes } from './routes/features/index.js'; import { createFeaturesRoutes } from './routes/features/index.js';
@@ -105,17 +112,43 @@ if (ENABLE_REQUEST_LOGGING) {
}) })
); );
} }
// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks // CORS configuration
// from malicious websites. MCP server endpoints can execute arbitrary commands, // When using credentials (cookies), origin cannot be '*'
// so allowing any origin would enable RCE from any website visited while Automaker runs. // We dynamically allow the requesting origin for local development
const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007'];
app.use( app.use(
cors({ cors({
origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS, origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps, curl, Electron)
if (!origin) {
callback(null, true);
return;
}
// If CORS_ORIGIN is set, use it (can be comma-separated list)
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
if (allowedOrigins.includes(origin)) {
callback(null, origin);
} else {
callback(new Error('Not allowed by CORS'));
}
return;
}
// For local development, allow localhost origins
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
callback(null, origin);
return;
}
// Reject other origins by default for security
callback(new Error('Not allowed by CORS'));
},
credentials: true, credentials: true,
}) })
); );
app.use(express.json({ limit: '50mb' })); app.use(express.json({ limit: '50mb' }));
app.use(cookieParser());
// Create shared event emitter for streaming // Create shared event emitter for streaming
const events: EventEmitter = createEventEmitter(); const events: EventEmitter = createEventEmitter();
@@ -144,12 +177,16 @@ setInterval(() => {
} }
}, VALIDATION_CLEANUP_INTERVAL_MS); }, VALIDATION_CLEANUP_INTERVAL_MS);
// Mount API routes - health is unauthenticated for monitoring // Mount API routes - health and auth are unauthenticated
app.use('/api/health', createHealthRoutes()); app.use('/api/health', createHealthRoutes());
app.use('/api/auth', createAuthRoutes());
// Apply authentication to all other routes // Apply authentication to all other routes
app.use('/api', authMiddleware); app.use('/api', authMiddleware);
// Protected health endpoint with detailed info
app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events)); app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events)); app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/sessions', createSessionsRoutes(agentService));
@@ -182,10 +219,70 @@ const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true }); const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService(); const terminalService = getTerminalService();
/**
* Authenticate WebSocket upgrade requests
* Checks for API key in header/query, session token in header/query, OR valid session cookie
*/
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
const url = new URL(request.url || '', `http://${request.headers.host}`);
// Check for API key in header (Electron mode)
const headerKey = request.headers['x-api-key'] as string | undefined;
if (headerKey && validateApiKey(headerKey)) {
return true;
}
// Check for session token in header (web mode with explicit token)
const sessionTokenHeader = request.headers['x-session-token'] as string | undefined;
if (sessionTokenHeader && validateSession(sessionTokenHeader)) {
return true;
}
// Check for API key in query param (fallback for WebSocket)
const queryKey = url.searchParams.get('apiKey');
if (queryKey && validateApiKey(queryKey)) {
return true;
}
// Check for session token in query param (fallback for WebSocket in web mode)
const queryToken = url.searchParams.get('sessionToken');
if (queryToken && validateSession(queryToken)) {
return true;
}
// Check for session cookie (web mode)
const cookieHeader = request.headers.cookie;
if (cookieHeader) {
const cookieName = getSessionCookieName();
const cookies = cookieHeader.split(';').reduce(
(acc, cookie) => {
const [key, value] = cookie.trim().split('=');
acc[key] = value;
return acc;
},
{} as Record<string, string>
);
const sessionToken = cookies[cookieName];
if (sessionToken && validateSession(sessionToken)) {
return true;
}
}
return false;
}
// Handle HTTP upgrade requests manually to route to correct WebSocket server // Handle HTTP upgrade requests manually to route to correct WebSocket server
server.on('upgrade', (request, socket, head) => { server.on('upgrade', (request, socket, head) => {
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`); const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
// Authenticate all WebSocket connections
if (!authenticateWebSocket(request)) {
console.log('[WebSocket] Authentication failed, rejecting connection');
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (pathname === '/api/events') { if (pathname === '/api/events') {
wss.handleUpgrade(request, socket, head, (ws) => { wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request); wss.emit('connection', ws, request);

View File

@@ -1,39 +1,224 @@
/** /**
* Authentication middleware for API security * Authentication middleware for API security
* *
* Supports API key authentication via header or environment variable. * Supports two authentication methods:
* 1. Header-based (X-API-Key) - Used by Electron mode
* 2. Cookie-based (HTTP-only session cookie) - Used by web mode
*
* Auto-generates an API key on first run if none is configured.
*/ */
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
// API key from environment (optional - if not set, auth is disabled) const DATA_DIR = process.env.DATA_DIR || './data';
const API_KEY = process.env.AUTOMAKER_API_KEY; const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
const SESSION_COOKIE_NAME = 'automaker_session';
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// Session store - persisted to file for survival across server restarts
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
/**
* Load sessions from file on startup
*/
function loadSessions(): void {
try {
if (fs.existsSync(SESSIONS_FILE)) {
const data = fs.readFileSync(SESSIONS_FILE, 'utf-8');
const sessions = JSON.parse(data) as Array<
[string, { createdAt: number; expiresAt: number }]
>;
const now = Date.now();
let loadedCount = 0;
let expiredCount = 0;
for (const [token, session] of sessions) {
// Only load non-expired sessions
if (session.expiresAt > now) {
validSessions.set(token, session);
loadedCount++;
} else {
expiredCount++;
}
}
if (loadedCount > 0 || expiredCount > 0) {
console.log(`[Auth] Loaded ${loadedCount} sessions (${expiredCount} expired)`);
}
}
} catch (error) {
console.warn('[Auth] Error loading sessions:', error);
}
}
/**
* Save sessions to file
*/
function saveSessions(): void {
try {
fs.mkdirSync(path.dirname(SESSIONS_FILE), { recursive: true });
const sessions = Array.from(validSessions.entries());
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions), { encoding: 'utf-8', mode: 0o600 });
} catch (error) {
console.error('[Auth] Failed to save sessions:', error);
}
}
// Load existing sessions on startup
loadSessions();
/**
* Ensure an API key exists - either from env var, file, or generate new one.
* This provides CSRF protection by requiring a secret key for all API requests.
*/
function ensureApiKey(): string {
// First check environment variable (Electron passes it this way)
if (process.env.AUTOMAKER_API_KEY) {
console.log('[Auth] Using API key from environment variable');
return process.env.AUTOMAKER_API_KEY;
}
// Try to read from file
try {
if (fs.existsSync(API_KEY_FILE)) {
const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim();
if (key) {
console.log('[Auth] Loaded API key from file');
return key;
}
}
} catch (error) {
console.warn('[Auth] Error reading API key file:', error);
}
// Generate new key
const newKey = crypto.randomUUID();
try {
fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
console.log('[Auth] Generated new API key');
} catch (error) {
console.error('[Auth] Failed to save API key:', error);
}
return newKey;
}
// API key - always generated/loaded on startup for CSRF protection
const API_KEY = ensureApiKey();
// Print API key to console for web mode users
console.log(`
╔═══════════════════════════════════════════════════════════════════════╗
║ 🔐 API Key for Web Mode Authentication ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ When accessing via browser, you'll be prompted to enter this key: ║
║ ║
${API_KEY}
║ ║
║ In Electron mode, authentication is handled automatically. ║
╚═══════════════════════════════════════════════════════════════════════╝
`);
/**
* Generate a cryptographically secure session token
*/
function generateSessionToken(): string {
return crypto.randomBytes(32).toString('hex');
}
/**
* Create a new session and return the token
*/
export function createSession(): string {
const token = generateSessionToken();
const now = Date.now();
validSessions.set(token, {
createdAt: now,
expiresAt: now + SESSION_MAX_AGE_MS,
});
saveSessions(); // Persist to file
return token;
}
/**
* Validate a session token
*/
export function validateSession(token: string): boolean {
const session = validSessions.get(token);
if (!session) return false;
if (Date.now() > session.expiresAt) {
validSessions.delete(token);
saveSessions(); // Persist removal
return false;
}
return true;
}
/**
* Invalidate a session token
*/
export function invalidateSession(token: string): void {
validSessions.delete(token);
saveSessions(); // Persist removal
}
/**
* Validate the API key
*/
export function validateApiKey(key: string): boolean {
return key === API_KEY;
}
/**
* Get session cookie options
*/
export function getSessionCookieOptions(): {
httpOnly: boolean;
secure: boolean;
sameSite: 'strict' | 'lax' | 'none';
maxAge: number;
path: string;
} {
return {
httpOnly: true, // JavaScript cannot access this cookie
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
maxAge: SESSION_MAX_AGE_MS,
path: '/',
};
}
/**
* Get the session cookie name
*/
export function getSessionCookieName(): string {
return SESSION_COOKIE_NAME;
}
/** /**
* Authentication middleware * Authentication middleware
* *
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header. * Accepts either:
* If not set, allows all requests (development mode). * 1. X-API-Key header (for Electron mode)
* 2. X-Session-Token header (for web mode with explicit token)
* 3. apiKey query parameter (fallback for cases where headers can't be set)
* 4. Session cookie (for web mode)
*/ */
export function authMiddleware(req: Request, res: Response, next: NextFunction): void { export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// If no API key is configured, allow all requests // Check for API key in header (Electron mode)
if (!API_KEY) { const headerKey = req.headers['x-api-key'] as string | undefined;
next(); if (headerKey) {
return; if (headerKey === API_KEY) {
} next();
return;
// Check for API key in header }
const providedKey = req.headers['x-api-key'] as string | undefined;
if (!providedKey) {
res.status(401).json({
success: false,
error: 'Authentication required. Provide X-API-Key header.',
});
return;
}
if (providedKey !== API_KEY) {
res.status(403).json({ res.status(403).json({
success: false, success: false,
error: 'Invalid API key.', error: 'Invalid API key.',
@@ -41,14 +226,53 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
return; return;
} }
next(); // Check for session token in header (web mode with explicit token)
const sessionTokenHeader = req.headers['x-session-token'] as string | undefined;
if (sessionTokenHeader) {
if (validateSession(sessionTokenHeader)) {
next();
return;
}
res.status(403).json({
success: false,
error: 'Invalid or expired session token.',
});
return;
}
// Check for API key in query parameter (fallback)
const queryKey = req.query.apiKey as string | undefined;
if (queryKey) {
if (queryKey === API_KEY) {
next();
return;
}
res.status(403).json({
success: false,
error: 'Invalid API key.',
});
return;
}
// Check for session cookie (web mode)
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME] as string | undefined;
if (sessionToken && validateSession(sessionToken)) {
next();
return;
}
// No valid authentication
res.status(401).json({
success: false,
error: 'Authentication required.',
});
} }
/** /**
* Check if authentication is enabled * Check if authentication is enabled (always true now)
*/ */
export function isAuthEnabled(): boolean { export function isAuthEnabled(): boolean {
return !!API_KEY; return true;
} }
/** /**
@@ -56,7 +280,38 @@ export function isAuthEnabled(): boolean {
*/ */
export function getAuthStatus(): { enabled: boolean; method: string } { export function getAuthStatus(): { enabled: boolean; method: string } {
return { return {
enabled: !!API_KEY, enabled: true,
method: API_KEY ? 'api_key' : 'none', method: 'api_key_or_session',
}; };
} }
/**
* Check if a request is authenticated (for status endpoint)
*/
export function isRequestAuthenticated(req: Request): boolean {
// Check API key header
const headerKey = req.headers['x-api-key'] as string | undefined;
if (headerKey && headerKey === API_KEY) {
return true;
}
// Check session token header
const sessionTokenHeader = req.headers['x-session-token'] as string | undefined;
if (sessionTokenHeader && validateSession(sessionTokenHeader)) {
return true;
}
// Check query parameter
const queryKey = req.query.apiKey as string | undefined;
if (queryKey && queryKey === API_KEY) {
return true;
}
// Check cookie
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME] as string | undefined;
if (sessionToken && validateSession(sessionToken)) {
return true;
}
return false;
}

View File

@@ -0,0 +1,150 @@
/**
* Auth routes - Login, logout, and status endpoints
*
* Security model:
* - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie
* - Electron mode: Uses X-API-Key header (handled automatically via IPC)
*
* The session cookie is:
* - HTTP-only: JavaScript cannot read it (protects against XSS)
* - SameSite=Strict: Only sent for same-site requests (protects against CSRF)
*
* Mounted at /api/auth in the main server (BEFORE auth middleware).
*/
import { Router } from 'express';
import {
validateApiKey,
createSession,
invalidateSession,
getSessionCookieOptions,
getSessionCookieName,
isRequestAuthenticated,
} from '../../lib/auth.js';
/**
* Create auth routes
*
* @returns Express Router with auth endpoints
*/
export function createAuthRoutes(): Router {
const router = Router();
/**
* GET /api/auth/status
*
* Returns whether the current request is authenticated.
* Used by the UI to determine if login is needed.
*/
router.get('/status', (req, res) => {
const authenticated = isRequestAuthenticated(req);
res.json({
success: true,
authenticated,
required: true,
});
});
/**
* POST /api/auth/login
*
* Validates the API key and sets a session cookie.
* Body: { apiKey: string }
*/
router.post('/login', (req, res) => {
const { apiKey } = req.body as { apiKey?: string };
if (!apiKey) {
res.status(400).json({
success: false,
error: 'API key is required.',
});
return;
}
if (!validateApiKey(apiKey)) {
res.status(401).json({
success: false,
error: 'Invalid API key.',
});
return;
}
// Create session and set cookie
const sessionToken = createSession();
const cookieOptions = getSessionCookieOptions();
const cookieName = getSessionCookieName();
res.cookie(cookieName, sessionToken, cookieOptions);
res.json({
success: true,
message: 'Logged in successfully.',
// Return token for explicit header-based auth (works around cross-origin cookie issues)
token: sessionToken,
});
});
/**
* GET /api/auth/token
*
* Returns the session token if the user has a valid session cookie.
* This allows the UI to get a token for explicit header-based auth
* after a page refresh (when the token in memory is lost).
*/
router.get('/token', (req, res) => {
const cookieName = getSessionCookieName();
const sessionToken = req.cookies?.[cookieName] as string | undefined;
if (!sessionToken) {
res.status(401).json({
success: false,
error: 'No session cookie found.',
});
return;
}
// Validate the session is still valid
if (!isRequestAuthenticated(req)) {
res.status(401).json({
success: false,
error: 'Session expired.',
});
return;
}
// Return the existing session token for header-based auth
res.json({
success: true,
token: sessionToken,
});
});
/**
* POST /api/auth/logout
*
* Clears the session cookie and invalidates the session.
*/
router.post('/logout', (req, res) => {
const cookieName = getSessionCookieName();
const sessionToken = req.cookies?.[cookieName] as string | undefined;
if (sessionToken) {
invalidateSession(sessionToken);
}
// Clear the cookie
res.clearCookie(cookieName, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
res.json({
success: true,
message: 'Logged out successfully.',
});
});
return router;
}

View File

@@ -1,16 +1,25 @@
/** /**
* Health check routes * Health check routes
*
* NOTE: Only the basic health check (/) is unauthenticated.
* The /detailed endpoint requires authentication.
*/ */
import { Router } from 'express'; import { Router } from 'express';
import { createIndexHandler } from './routes/index.js'; import { createIndexHandler } from './routes/index.js';
import { createDetailedHandler } from './routes/detailed.js';
/**
* Create unauthenticated health routes (basic check only)
* Used by load balancers and container orchestration
*/
export function createHealthRoutes(): Router { export function createHealthRoutes(): Router {
const router = Router(); const router = Router();
// Basic health check - no sensitive info
router.get('/', createIndexHandler()); router.get('/', createIndexHandler());
router.get('/detailed', createDetailedHandler());
return router; return router;
} }
// Re-export detailed handler for use in authenticated routes
export { createDetailedHandler } from './routes/detailed.js';

View File

@@ -12,12 +12,13 @@ import { ClaudeUsage } from '../routes/claude/types.js';
* *
* Platform-specific implementations: * Platform-specific implementations:
* - macOS: Uses 'expect' command for PTY * - macOS: Uses 'expect' command for PTY
* - Windows: Uses node-pty for PTY * - Windows/Linux: Uses node-pty for PTY
*/ */
export class ClaudeUsageService { export class ClaudeUsageService {
private claudeBinary = 'claude'; private claudeBinary = 'claude';
private timeout = 30000; // 30 second timeout private timeout = 30000; // 30 second timeout
private isWindows = os.platform() === 'win32'; private isWindows = os.platform() === 'win32';
private isLinux = os.platform() === 'linux';
/** /**
* Check if Claude CLI is available on the system * Check if Claude CLI is available on the system
@@ -48,8 +49,8 @@ export class ClaudeUsageService {
* Uses platform-specific PTY implementation * Uses platform-specific PTY implementation
*/ */
private executeClaudeUsageCommand(): Promise<string> { private executeClaudeUsageCommand(): Promise<string> {
if (this.isWindows) { if (this.isWindows || this.isLinux) {
return this.executeClaudeUsageCommandWindows(); return this.executeClaudeUsageCommandPty();
} }
return this.executeClaudeUsageCommandMac(); return this.executeClaudeUsageCommandMac();
} }
@@ -147,17 +148,23 @@ export class ClaudeUsageService {
} }
/** /**
* Windows implementation using node-pty * Windows/Linux implementation using node-pty
*/ */
private executeClaudeUsageCommandWindows(): Promise<string> { private executeClaudeUsageCommandPty(): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let output = ''; let output = '';
let settled = false; let settled = false;
let hasSeenUsageData = false; let hasSeenUsageData = false;
const workingDirectory = process.env.USERPROFILE || os.homedir() || 'C:\\'; const workingDirectory = this.isWindows
? process.env.USERPROFILE || os.homedir() || 'C:\\'
: process.env.HOME || os.homedir() || '/tmp';
const ptyProcess = pty.spawn('cmd.exe', ['/c', 'claude', '/usage'], { // Use platform-appropriate shell and command
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
const ptyProcess = pty.spawn(shell, args, {
name: 'xterm-256color', name: 'xterm-256color',
cols: 120, cols: 120,
rows: 30, rows: 30,

View File

@@ -5,6 +5,7 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
// Error codes for distinguishing failure modes // Error codes for distinguishing failure modes
const ERROR_CODES = { const ERROR_CODES = {
@@ -25,10 +26,15 @@ const REFRESH_INTERVAL_SECONDS = 45;
export function ClaudeUsagePopover() { export function ClaudeUsagePopover() {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<UsageError | null>(null); const [error, setError] = useState<UsageError | null>(null);
// Check if CLI is verified/authenticated
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes // Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
const isStale = useMemo(() => { const isStale = useMemo(() => {
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
@@ -68,14 +74,17 @@ export function ClaudeUsagePopover() {
[setClaudeUsage] [setClaudeUsage]
); );
// Auto-fetch on mount if data is stale // Auto-fetch on mount if data is stale (only if CLI is verified)
useEffect(() => { useEffect(() => {
if (isStale) { if (isStale && isCliVerified) {
fetchUsage(true); fetchUsage(true);
} }
}, [isStale, fetchUsage]); }, [isStale, isCliVerified, fetchUsage]);
useEffect(() => { useEffect(() => {
// Skip if CLI is not verified
if (!isCliVerified) return;
// Initial fetch when opened // Initial fetch when opened
if (open) { if (open) {
if (!claudeUsage || isStale) { if (!claudeUsage || isStale) {
@@ -94,7 +103,7 @@ export function ClaudeUsagePopover() {
return () => { return () => {
if (intervalId) clearInterval(intervalId); if (intervalId) clearInterval(intervalId);
}; };
}, [open, claudeUsage, isStale, fetchUsage]); }, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
// Derived status color/icon helper // Derived status color/icon helper
const getStatusInfo = (percentage: number) => { const getStatusInfo = (percentage: number) => {

View File

@@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { getJSON, setJSON } from '@/lib/storage'; import { getJSON, setJSON } from '@/lib/storage';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
import { useOSDetection } from '@/hooks'; import { useOSDetection } from '@/hooks';
import { apiPost } from '@/lib/api-fetch';
interface DirectoryEntry { interface DirectoryEntry {
name: string; name: string;
@@ -98,16 +99,7 @@ export function FileBrowserDialog({
setWarning(''); setWarning('');
try { try {
// Get server URL from environment or default const result = await apiPost<BrowseResult>('/api/fs/browse', { dirPath });
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dirPath }),
});
const result: BrowseResult = await response.json();
if (result.success) { if (result.success) {
setCurrentPath(result.currentPath); setCurrentPath(result.currentPath);

View File

@@ -7,6 +7,7 @@ import { Plus, Bot, Wand2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover'; import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
interface BoardHeaderProps { interface BoardHeaderProps {
projectName: string; projectName: string;
@@ -34,12 +35,18 @@ export function BoardHeader({
isMounted, isMounted,
}: BoardHeaderProps) { }: BoardHeaderProps) {
const apiKeys = useAppStore((state) => state.apiKeys); const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users) // Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Also hide on Windows for now (CLI usage command not supported) // Also hide on Windows for now (CLI usage command not supported)
// Only show if CLI has been verified/authenticated
const isWindows = const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows; const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
return ( return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md"> <div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">

View File

@@ -0,0 +1,104 @@
/**
* Login View - Web mode authentication
*
* Prompts user to enter the API key shown in server console.
* On successful login, sets an HTTP-only session cookie.
*/
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { login } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
export function LoginView() {
const navigate = useNavigate();
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const result = await login(apiKey.trim());
if (result.success) {
// Redirect to home/board on success
navigate({ to: '/' });
} else {
setError(result.error || 'Invalid API key');
}
} catch (err) {
setError('Failed to connect to server');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8">
{/* Header */}
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<KeyRound className="h-8 w-8 text-primary" />
</div>
<h1 className="mt-6 text-2xl font-bold tracking-tight">Authentication Required</h1>
<p className="mt-2 text-sm text-muted-foreground">
Enter the API key shown in the server console to continue.
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
API Key
</label>
<Input
id="apiKey"
type="password"
placeholder="Enter API key..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={isLoading}
autoFocus
className="font-mono"
/>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading || !apiKey.trim()}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
</>
) : (
'Login'
)}
</Button>
</form>
{/* Help Text */}
<div className="rounded-lg border bg-muted/50 p-4 text-sm">
<p className="font-medium">Where to find the API key:</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-muted-foreground">
<li>Look at the server terminal/console output</li>
<li>Find the box labeled "API Key for Web Mode Authentication"</li>
<li>Copy the UUID displayed there</li>
</ol>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useCliStatus, useSettingsView } from './settings-view/hooks'; import { useCliStatus, useSettingsView } from './settings-view/hooks';
import { NAV_ITEMS } from './settings-view/config/navigation'; import { NAV_ITEMS } from './settings-view/config/navigation';
@@ -55,11 +56,15 @@ export function SettingsView() {
setEnableSandboxMode, setEnableSandboxMode,
} = useAppStore(); } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users) // Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Also hide on Windows for now (CLI usage command not supported) // Also hide on Windows for now (CLI usage command not supported)
const isWindows = const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows; const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const showUsageTracking = !hasApiKey && !isWindows;
// Convert electron Project to settings-view Project type // Convert electron Project to settings-view Project type
const convertProject = (project: ElectronProject | null): SettingsProject | null => { const convertProject = (project: ElectronProject | null): SettingsProject | null => {

View File

@@ -46,6 +46,8 @@ import {
defaultDropAnimationSideEffects, defaultDropAnimationSideEffects,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { apiFetch, apiGet, apiPost, apiDeleteRaw, getAuthHeaders } from '@/lib/api-fetch';
import { getApiKey } from '@/lib/http-api-client';
interface TerminalStatus { interface TerminalStatus {
enabled: boolean; enabled: boolean;
@@ -304,16 +306,13 @@ export function TerminalView() {
await Promise.allSettled( await Promise.allSettled(
sessionIds.map(async (sessionId) => { sessionIds.map(async (sessionId) => {
try { try {
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
method: 'DELETE',
headers,
});
} catch (err) { } catch (err) {
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err); console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
} }
}) })
); );
}, [collectAllSessionIds, terminalState.authToken, serverUrl]); }, [collectAllSessionIds, terminalState.authToken]);
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Helper to check if terminal creation should be debounced // Helper to check if terminal creation should be debounced
@@ -434,9 +433,10 @@ export function TerminalView() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const response = await fetch(`${serverUrl}/api/terminal/status`); const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
const data = await response.json(); '/api/terminal/status'
if (data.success) { );
if (data.success && data.data) {
setStatus(data.data); setStatus(data.data);
if (!data.data.passwordRequired) { if (!data.data.passwordRequired) {
setTerminalUnlocked(true); setTerminalUnlocked(true);
@@ -450,7 +450,7 @@ export function TerminalView() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [serverUrl, setTerminalUnlocked]); }, [setTerminalUnlocked]);
// Fetch server session settings // Fetch server session settings
const fetchServerSettings = useCallback(async () => { const fetchServerSettings = useCallback(async () => {
@@ -460,15 +460,17 @@ export function TerminalView() {
if (terminalState.authToken) { if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken; headers['X-Terminal-Token'] = terminalState.authToken;
} }
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers }); const data = await apiGet<{
const data = await response.json(); success: boolean;
if (data.success) { data?: { currentSessions: number; maxSessions: number };
}>('/api/terminal/settings', { headers });
if (data.success && data.data) {
setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions }); setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions });
} }
} catch (err) { } catch (err) {
console.error('[Terminal] Failed to fetch server settings:', err); console.error('[Terminal] Failed to fetch server settings:', err);
} }
}, [serverUrl, terminalState.isUnlocked, terminalState.authToken]); }, [terminalState.isUnlocked, terminalState.authToken]);
useEffect(() => { useEffect(() => {
fetchStatus(); fetchStatus();
@@ -483,22 +485,20 @@ export function TerminalView() {
const sessionIds = collectAllSessionIds(); const sessionIds = collectAllSessionIds();
if (sessionIds.length === 0) return; if (sessionIds.length === 0) return;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
// Try to use the bulk delete endpoint if available, otherwise delete individually // Try to use the bulk delete endpoint if available, otherwise delete individually
// Using sendBeacon for reliability during page unload // Using sync XMLHttpRequest for reliability during page unload (async doesn't complete)
sessionIds.forEach((sessionId) => { sessionIds.forEach((sessionId) => {
const url = `${serverUrl}/api/terminal/sessions/${sessionId}`; const url = `${serverUrl}/api/terminal/sessions/${sessionId}`;
// sendBeacon doesn't support DELETE method, so we'll use a sync XMLHttpRequest
// which is more reliable during page unload than fetch
try { try {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('DELETE', url, false); // synchronous xhr.open('DELETE', url, false); // synchronous
xhr.withCredentials = true; // Include cookies for session auth
// Add API auth header
const apiKey = getApiKey();
if (apiKey) {
xhr.setRequestHeader('X-API-Key', apiKey);
}
// Add terminal-specific auth
if (terminalState.authToken) { if (terminalState.authToken) {
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken); xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
} }
@@ -593,9 +593,7 @@ export function TerminalView() {
let reconnectedSessions = 0; let reconnectedSessions = 0;
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {};
'Content-Type': 'application/json',
};
// Get fresh auth token from store // Get fresh auth token from store
const authToken = useAppStore.getState().terminalState.authToken; const authToken = useAppStore.getState().terminalState.authToken;
if (authToken) { if (authToken) {
@@ -605,11 +603,9 @@ export function TerminalView() {
// Helper to check if a session still exists on server // Helper to check if a session still exists on server
const checkSessionExists = async (sessionId: string): Promise<boolean> => { const checkSessionExists = async (sessionId: string): Promise<boolean> => {
try { try {
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { const data = await apiGet<{ success: boolean }>(`/api/terminal/sessions/${sessionId}`, {
method: 'GET',
headers, headers,
}); });
const data = await response.json();
return data.success === true; return data.success === true;
} catch { } catch {
return false; return false;
@@ -619,17 +615,12 @@ export function TerminalView() {
// Helper to create a new terminal session // Helper to create a new terminal session
const createSession = async (): Promise<string | null> => { const createSession = async (): Promise<string | null> => {
try { try {
const response = await fetch(`${serverUrl}/api/terminal/sessions`, { const data = await apiPost<{ success: boolean; data?: { id: string } }>(
method: 'POST', '/api/terminal/sessions',
headers, { cwd: currentPath, cols: 80, rows: 24 },
body: JSON.stringify({ { headers }
cwd: currentPath, );
cols: 80, return data.success && data.data ? data.data.id : null;
rows: 24,
}),
});
const data = await response.json();
return data.success ? data.data.id : null;
} catch (err) { } catch (err) {
console.error('[Terminal] Failed to create terminal session:', err); console.error('[Terminal] Failed to create terminal session:', err);
return null; return null;
@@ -801,14 +792,12 @@ export function TerminalView() {
setAuthError(null); setAuthError(null);
try { try {
const response = await fetch(`${serverUrl}/api/terminal/auth`, { const data = await apiPost<{ success: boolean; data?: { token: string }; error?: string }>(
method: 'POST', '/api/terminal/auth',
headers: { 'Content-Type': 'application/json' }, { password }
body: JSON.stringify({ password }), );
});
const data = await response.json();
if (data.success) { if (data.success && data.data) {
setTerminalUnlocked(true, data.data.token); setTerminalUnlocked(true, data.data.token);
setPassword(''); setPassword('');
} else { } else {
@@ -833,21 +822,14 @@ export function TerminalView() {
} }
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {};
'Content-Type': 'application/json',
};
if (terminalState.authToken) { if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken; headers['X-Terminal-Token'] = terminalState.authToken;
} }
const response = await fetch(`${serverUrl}/api/terminal/sessions`, { const response = await apiFetch('/api/terminal/sessions', 'POST', {
method: 'POST',
headers, headers,
body: JSON.stringify({ body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
}); });
const data = await response.json(); const data = await response.json();
@@ -892,21 +874,14 @@ export function TerminalView() {
const tabId = addTerminalTab(); const tabId = addTerminalTab();
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {};
'Content-Type': 'application/json',
};
if (terminalState.authToken) { if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken; headers['X-Terminal-Token'] = terminalState.authToken;
} }
const response = await fetch(`${serverUrl}/api/terminal/sessions`, { const response = await apiFetch('/api/terminal/sessions', 'POST', {
method: 'POST',
headers, headers,
body: JSON.stringify({ body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
}); });
const data = await response.json(); const data = await response.json();
@@ -959,10 +934,7 @@ export function TerminalView() {
headers['X-Terminal-Token'] = terminalState.authToken; headers['X-Terminal-Token'] = terminalState.authToken;
} }
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { const response = await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
method: 'DELETE',
headers,
});
// Always remove from UI - even if server says 404 (session may have already exited) // Always remove from UI - even if server says 404 (session may have already exited)
removeTerminalFromLayout(sessionId); removeTerminalFromLayout(sessionId);
@@ -1008,10 +980,7 @@ export function TerminalView() {
await Promise.all( await Promise.all(
sessionIds.map(async (sessionId) => { sessionIds.map(async (sessionId) => {
try { try {
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
method: 'DELETE',
headers,
});
} catch (err) { } catch (err) {
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err); console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
} }

View File

@@ -40,6 +40,7 @@ import {
} from '@/config/terminal-themes'; } from '@/config/terminal-themes';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { getApiKey } from '@/lib/http-api-client';
// Font size constraints // Font size constraints
const MIN_FONT_SIZE = 8; const MIN_FONT_SIZE = 8;
@@ -940,8 +941,17 @@ export function TerminalPanel({
if (!terminal) return; if (!terminal) return;
const connect = () => { const connect = () => {
// Build WebSocket URL with token // Build WebSocket URL with auth params
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`; let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
// Add API key for Electron mode auth
const apiKey = getApiKey();
if (apiKey) {
url += `&apiKey=${encodeURIComponent(apiKey)}`;
}
// In web mode, cookies are sent automatically with same-origin WebSocket
// Add terminal password token if required
if (authToken) { if (authToken) {
url += `&token=${encodeURIComponent(authToken)}`; url += `&token=${encodeURIComponent(authToken)}`;
} }

View File

@@ -0,0 +1,161 @@
/**
* Authenticated fetch utility
*
* Provides a wrapper around fetch that automatically includes:
* - X-API-Key header (for Electron mode)
* - X-Session-Token header (for web mode with explicit token)
* - credentials: 'include' (fallback for web mode session cookies)
*
* Use this instead of raw fetch() for all authenticated API calls.
*/
import { getApiKey, getSessionToken } from './http-api-client';
// Server URL - configurable via environment variable
const getServerUrl = (): string => {
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
}
return 'http://localhost:3008';
};
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface ApiFetchOptions extends Omit<RequestInit, 'method' | 'headers' | 'body'> {
/** Additional headers to include (merged with auth headers) */
headers?: Record<string, string>;
/** Request body - will be JSON stringified if object */
body?: unknown;
/** Skip authentication headers (for public endpoints like /api/health) */
skipAuth?: boolean;
}
/**
* Build headers for an authenticated request
*/
export function getAuthHeaders(additionalHeaders?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...additionalHeaders,
};
// Electron mode: use API key
const apiKey = getApiKey();
if (apiKey) {
headers['X-API-Key'] = apiKey;
return headers;
}
// Web mode: use session token if available
const sessionToken = getSessionToken();
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
return headers;
}
/**
* Make an authenticated fetch request to the API
*
* @param endpoint - API endpoint (e.g., '/api/fs/browse')
* @param method - HTTP method
* @param options - Additional options
* @returns Response from fetch
*
* @example
* ```ts
* // Simple GET
* const response = await apiFetch('/api/terminal/status', 'GET');
*
* // POST with body
* const response = await apiFetch('/api/fs/browse', 'POST', {
* body: { dirPath: '/home/user' }
* });
*
* // With additional headers
* const response = await apiFetch('/api/terminal/sessions', 'POST', {
* headers: { 'X-Terminal-Token': token },
* body: { cwd: '/home/user' }
* });
* ```
*/
export async function apiFetch(
endpoint: string,
method: HttpMethod = 'GET',
options: ApiFetchOptions = {}
): Promise<Response> {
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
const headers = skipAuth
? { 'Content-Type': 'application/json', ...additionalHeaders }
: getAuthHeaders(additionalHeaders);
const fetchOptions: RequestInit = {
method,
headers,
credentials: 'include',
...restOptions,
};
if (body !== undefined) {
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
}
const url = endpoint.startsWith('http') ? endpoint : `${getServerUrl()}${endpoint}`;
return fetch(url, fetchOptions);
}
/**
* Make an authenticated GET request
*/
export async function apiGet<T>(
endpoint: string,
options: Omit<ApiFetchOptions, 'body'> = {}
): Promise<T> {
const response = await apiFetch(endpoint, 'GET', options);
return response.json();
}
/**
* Make an authenticated POST request
*/
export async function apiPost<T>(
endpoint: string,
body?: unknown,
options: ApiFetchOptions = {}
): Promise<T> {
const response = await apiFetch(endpoint, 'POST', { ...options, body });
return response.json();
}
/**
* Make an authenticated PUT request
*/
export async function apiPut<T>(
endpoint: string,
body?: unknown,
options: ApiFetchOptions = {}
): Promise<T> {
const response = await apiFetch(endpoint, 'PUT', { ...options, body });
return response.json();
}
/**
* Make an authenticated DELETE request
*/
export async function apiDelete<T>(endpoint: string, options: ApiFetchOptions = {}): Promise<T> {
const response = await apiFetch(endpoint, 'DELETE', options);
return response.json();
}
/**
* Make an authenticated DELETE request (returns raw response for status checking)
*/
export async function apiDeleteRaw(
endpoint: string,
options: ApiFetchOptions = {}
): Promise<Response> {
return apiFetch(endpoint, 'DELETE', options);
}

View File

@@ -431,6 +431,7 @@ export interface SaveImageResult {
export interface ElectronAPI { export interface ElectronAPI {
ping: () => Promise<string>; ping: () => Promise<string>;
getApiKey?: () => Promise<string | null>;
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
openDirectory: () => Promise<DialogResult>; openDirectory: () => Promise<DialogResult>;
openFile: (options?: object) => Promise<DialogResult>; openFile: (options?: object) => Promise<DialogResult>;

View File

@@ -41,12 +41,163 @@ const getServerUrl = (): string => {
return 'http://localhost:3008'; return 'http://localhost:3008';
}; };
// Get API key from environment variable // Cached API key for authentication (Electron mode only)
const getApiKey = (): string | null => { let cachedApiKey: string | null = null;
if (typeof window !== 'undefined') { let apiKeyInitialized = false;
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
// Cached session token for authentication (Web mode - explicit header auth)
let cachedSessionToken: string | null = null;
// Get API key for Electron mode (returns cached value after initialization)
// Exported for use in WebSocket connections that need auth
export const getApiKey = (): string | null => cachedApiKey;
// Get session token for Web mode (returns cached value after login or token fetch)
export const getSessionToken = (): string | null => cachedSessionToken;
// Set session token (called after login or token fetch)
export const setSessionToken = (token: string | null): void => {
cachedSessionToken = token;
};
// Clear session token (called on logout)
export const clearSessionToken = (): void => {
cachedSessionToken = null;
};
/**
* Check if we're running in Electron mode
*/
export const isElectronMode = (): boolean => {
return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey;
};
/**
* Initialize API key for Electron mode authentication.
* In web mode, authentication uses HTTP-only cookies instead.
*
* This should be called early in app initialization.
*/
export const initApiKey = async (): Promise<void> => {
if (apiKeyInitialized) return;
apiKeyInitialized = true;
// Only Electron mode uses API key header auth
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
try {
cachedApiKey = await window.electronAPI.getApiKey();
if (cachedApiKey) {
console.log('[HTTP Client] Using API key from Electron');
return;
}
} catch (error) {
console.warn('[HTTP Client] Failed to get API key from Electron:', error);
}
}
// In web mode, authentication is handled via HTTP-only cookies
console.log('[HTTP Client] Web mode - using cookie-based authentication');
};
/**
* Check authentication status with the server
*/
export const checkAuthStatus = async (): Promise<{
authenticated: boolean;
required: boolean;
}> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
credentials: 'include',
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
});
const data = await response.json();
return {
authenticated: data.authenticated ?? false,
required: data.required ?? true,
};
} catch (error) {
console.error('[HTTP Client] Failed to check auth status:', error);
return { authenticated: false, required: true };
}
};
/**
* Login with API key (for web mode)
*/
export const login = async (
apiKey: string
): Promise<{ success: boolean; error?: string; token?: string }> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ apiKey }),
});
const data = await response.json();
// Store the session token if login succeeded
if (data.success && data.token) {
setSessionToken(data.token);
console.log('[HTTP Client] Session token stored after login');
}
return data;
} catch (error) {
console.error('[HTTP Client] Login failed:', error);
return { success: false, error: 'Network error' };
}
};
/**
* Fetch session token from server (for page refresh when cookie exists)
* This retrieves the session token so it can be used for explicit header-based auth.
*/
export const fetchSessionToken = async (): Promise<boolean> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/token`, {
credentials: 'include', // Send the session cookie
});
if (!response.ok) {
console.log('[HTTP Client] No valid session to get token from');
return false;
}
const data = await response.json();
if (data.success && data.token) {
setSessionToken(data.token);
console.log('[HTTP Client] Session token retrieved from cookie session');
return true;
}
return false;
} catch (error) {
console.error('[HTTP Client] Failed to fetch session token:', error);
return false;
}
};
/**
* Logout (for web mode)
*/
export const logout = async (): Promise<{ success: boolean }> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/logout`, {
method: 'POST',
credentials: 'include',
});
// Clear the cached session token
clearSessionToken();
console.log('[HTTP Client] Session token cleared on logout');
return await response.json();
} catch (error) {
console.error('[HTTP Client] Logout failed:', error);
return { success: false };
} }
return null;
}; };
type EventType = type EventType =
@@ -87,7 +238,22 @@ export class HttpApiClient implements ElectronAPI {
this.isConnecting = true; this.isConnecting = true;
try { try {
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; let wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
// In Electron mode, add API key as query param for WebSocket auth
// (WebSocket doesn't support custom headers in browser)
const apiKey = getApiKey();
if (apiKey) {
wsUrl += `?apiKey=${encodeURIComponent(apiKey)}`;
} else {
// In web mode, add session token as query param
// (cookies may not work cross-origin, so use explicit token)
const sessionToken = getSessionToken();
if (sessionToken) {
wsUrl += `?sessionToken=${encodeURIComponent(sessionToken)}`;
}
}
this.ws = new WebSocket(wsUrl); this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => { this.ws.onopen = () => {
@@ -155,10 +321,20 @@ export class HttpApiClient implements ElectronAPI {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
// Electron mode: use API key
const apiKey = getApiKey(); const apiKey = getApiKey();
if (apiKey) { if (apiKey) {
headers['X-API-Key'] = apiKey; headers['X-API-Key'] = apiKey;
return headers;
} }
// Web mode: use session token if available
const sessionToken = getSessionToken();
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
return headers; return headers;
} }
@@ -166,14 +342,17 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}${endpoint}`, { const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
return response.json(); return response.json();
} }
private async get<T>(endpoint: string): Promise<T> { private async get<T>(endpoint: string): Promise<T> {
const headers = this.getHeaders(); const response = await fetch(`${this.serverUrl}${endpoint}`, {
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers }); headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
});
return response.json(); return response.json();
} }
@@ -181,6 +360,7 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}${endpoint}`, { const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'PUT', method: 'PUT',
headers: this.getHeaders(), headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
return response.json(); return response.json();
@@ -190,6 +370,7 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}${endpoint}`, { const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'DELETE', method: 'DELETE',
headers: this.getHeaders(), headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
}); });
return response.json(); return response.json();
} }

View File

@@ -8,6 +8,7 @@
import path from 'path'; import path from 'path';
import { spawn, ChildProcess } from 'child_process'; import { spawn, ChildProcess } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import crypto from 'crypto';
import http, { Server } from 'http'; import http, { Server } from 'http';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron'; import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
@@ -59,6 +60,46 @@ interface WindowBounds {
// Debounce timer for saving window bounds // Debounce timer for saving window bounds
let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null; let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
// API key for CSRF protection
let apiKey: string | null = null;
/**
* Get path to API key file in user data directory
*/
function getApiKeyPath(): string {
return path.join(app.getPath('userData'), '.api-key');
}
/**
* Ensure an API key exists - load from file or generate new one.
* This key is passed to the server for CSRF protection.
*/
function ensureApiKey(): string {
const keyPath = getApiKeyPath();
try {
if (fs.existsSync(keyPath)) {
const key = fs.readFileSync(keyPath, 'utf-8').trim();
if (key) {
apiKey = key;
console.log('[Electron] Loaded existing API key');
return apiKey;
}
}
} catch (error) {
console.warn('[Electron] Error reading API key:', error);
}
// Generate new key
apiKey = crypto.randomUUID();
try {
fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 });
console.log('[Electron] Generated new API key');
} catch (error) {
console.error('[Electron] Failed to save API key:', error);
}
return apiKey;
}
/** /**
* Get icon path - works in both dev and production, cross-platform * Get icon path - works in both dev and production, cross-platform
*/ */
@@ -331,6 +372,8 @@ async function startServer(): Promise<void> {
PORT: SERVER_PORT.toString(), PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath('userData'), DATA_DIR: app.getPath('userData'),
NODE_PATH: serverNodeModules, NODE_PATH: serverNodeModules,
// Pass API key to server for CSRF protection
AUTOMAKER_API_KEY: apiKey!,
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment // Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
// If not set, server will allow access to all paths // If not set, server will allow access to all paths
...(process.env.ALLOWED_ROOT_DIRECTORY && { ...(process.env.ALLOWED_ROOT_DIRECTORY && {
@@ -509,6 +552,9 @@ app.whenReady().then(async () => {
} }
} }
// Generate or load API key for CSRF protection (before starting server)
ensureApiKey();
try { try {
// Start static file server in production // Start static file server in production
if (app.isPackaged) { if (app.isPackaged) {
@@ -666,6 +712,11 @@ ipcMain.handle('server:getUrl', async () => {
return `http://localhost:${SERVER_PORT}`; return `http://localhost:${SERVER_PORT}`;
}); });
// Get API key for authentication
ipcMain.handle('auth:getApiKey', () => {
return apiKey;
});
// Window management - update minimum width based on sidebar state // Window management - update minimum width based on sidebar state
// Now uses a fixed small minimum since horizontal scrolling handles overflow // Now uses a fixed small minimum since horizontal scrolling handles overflow
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => { ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {

View File

@@ -19,6 +19,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Get server URL for HTTP client // Get server URL for HTTP client
getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'), getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'),
// Get API key for authentication
getApiKey: (): Promise<string | null> => ipcRenderer.invoke('auth:getApiKey'),
// Native dialogs - better UX than prompt() // Native dialogs - better UX than prompt()
openDirectory: (): Promise<Electron.OpenDialogReturnValue> => openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:openDirectory'), ipcRenderer.invoke('dialog:openDirectory'),

View File

@@ -9,6 +9,12 @@ import {
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import {
initApiKey,
checkAuthStatus,
isElectronMode,
fetchSessionToken,
} from '@/lib/http-api-client';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options'; import { ThemeOption, themeOptions } from '@/config/theme-options';
@@ -22,6 +28,8 @@ function RootLayoutContent() {
const [setupHydrated, setSetupHydrated] = useState( const [setupHydrated, setSetupHydrated] = useState(
() => useSetupStore.persist?.hasHydrated?.() ?? false () => useSetupStore.persist?.hasHydrated?.() ?? false
); );
const [authChecked, setAuthChecked] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const { openFileBrowser } = useFileBrowser(); const { openFileBrowser } = useFileBrowser();
// Hidden streamer panel - opens with "\" key // Hidden streamer panel - opens with "\" key
@@ -70,6 +78,51 @@ function RootLayoutContent() {
setIsMounted(true); setIsMounted(true);
}, []); }, []);
// Initialize authentication
// - Electron mode: Uses API key from IPC (header-based auth)
// - Web mode: Uses session token (fetched from cookie session for explicit header auth)
useEffect(() => {
const initAuth = async () => {
try {
// Initialize API key for Electron mode
await initApiKey();
// In Electron mode, we're always authenticated via header
if (isElectronMode()) {
setIsAuthenticated(true);
setAuthChecked(true);
return;
}
// In web mode, try to fetch session token (works if cookie is valid)
// This allows explicit header-based auth which works better cross-origin
const tokenFetched = await fetchSessionToken();
if (tokenFetched) {
// We have a valid session - token is now stored in memory
setIsAuthenticated(true);
setAuthChecked(true);
return;
}
// Fallback: check auth status via cookie
const status = await checkAuthStatus();
setIsAuthenticated(status.authenticated);
setAuthChecked(true);
// Redirect to login if not authenticated and not already on login page
if (!status.authenticated && location.pathname !== '/login') {
navigate({ to: '/login' });
}
} catch (error) {
console.error('Failed to initialize auth:', error);
setAuthChecked(true);
}
};
initAuth();
}, [location.pathname, navigate]);
// Wait for setup store hydration before enforcing routing rules // Wait for setup store hydration before enforcing routing rules
useEffect(() => { useEffect(() => {
if (useSetupStore.persist?.hasHydrated?.()) { if (useSetupStore.persist?.hasHydrated?.()) {
@@ -147,8 +200,32 @@ function RootLayoutContent() {
} }
}, [deferredTheme]); }, [deferredTheme]);
// Setup view is full-screen without sidebar // Login and setup views are full-screen without sidebar
const isSetupRoute = location.pathname === '/setup'; const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';
// Show login page (full screen, no sidebar)
if (isLoginRoute) {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<Outlet />
</main>
);
}
// Wait for auth check before rendering protected routes (web mode only)
if (!isElectronMode() && !authChecked) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<div className="text-muted-foreground">Loading...</div>
</main>
);
}
// Redirect to login if not authenticated (web mode)
if (!isElectronMode() && !isAuthenticated) {
return null; // Will redirect via useEffect
}
if (isSetupRoute) { if (isSetupRoute) {
return ( return (

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { LoginView } from '@/components/views/login-view';
export const Route = createFileRoute('/login')({
component: LoginView,
});

View File

@@ -176,6 +176,7 @@ export const useSetupStore = create<SetupState & SetupActions>()(
isFirstRun: state.isFirstRun, isFirstRun: state.isFirstRun,
setupComplete: state.setupComplete, setupComplete: state.setupComplete,
skipClaudeSetup: state.skipClaudeSetup, skipClaudeSetup: state.skipClaudeSetup,
claudeAuthStatus: state.claudeAuthStatus,
}), }),
} }
) )

View File

@@ -464,6 +464,7 @@ export interface AutoModeAPI {
export interface ElectronAPI { export interface ElectronAPI {
ping: () => Promise<string>; ping: () => Promise<string>;
getApiKey?: () => Promise<string | null>;
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
// Dialog APIs // Dialog APIs

View File

@@ -36,7 +36,7 @@ services:
# Required # Required
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
# Optional - authentication (leave empty to disable) # Optional - authentication, one will generate if left blank
- AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-} - AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-}
# Optional - restrict to specific directory within container only # Optional - restrict to specific directory within container only
@@ -49,7 +49,7 @@ services:
- DATA_DIR=/data - DATA_DIR=/data
# Optional - CORS origin (default allows all) # Optional - CORS origin (default allows all)
- CORS_ORIGIN=${CORS_ORIGIN:-*} - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007}
volumes: volumes:
# ONLY named volumes - these are isolated from your host filesystem # ONLY named volumes - these are isolated from your host filesystem
# This volume persists data between restarts but is container-managed # This volume persists data between restarts but is container-managed

View File

@@ -352,14 +352,21 @@ async function main() {
fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true }); fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true });
} }
// Start server in background // Start server in background, showing output in console AND logging to file
const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log'));
serverProcess = runNpm(['run', 'dev:server'], { serverProcess = runNpm(['run', 'dev:server'], {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
serverProcess.stdout?.pipe(logStream); // Pipe to both log file and console so user can see API key
serverProcess.stderr?.pipe(logStream); serverProcess.stdout?.on('data', (data) => {
process.stdout.write(data);
logStream.write(data);
});
serverProcess.stderr?.on('data', (data) => {
process.stderr.write(data);
logStream.write(data);
});
log('Waiting for server to be ready...', 'yellow'); log('Waiting for server to be ready...', 'yellow');

123
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"@automaker/types": "^1.0.0", "@automaker/types": "^1.0.0",
"@automaker/utils": "^1.0.0", "@automaker/utils": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.1", "@modelcontextprotocol/sdk": "^1.25.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
@@ -45,6 +46,7 @@
"ws": "^8.18.3" "ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/morgan": "^1.9.10", "@types/morgan": "^1.9.10",
@@ -452,7 +454,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -1036,7 +1037,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -1079,7 +1079,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -1246,7 +1245,7 @@
}, },
"node_modules/@electron/node-gyp": { "node_modules/@electron/node-gyp": {
"version": "10.2.0-electron.1", "version": "10.2.0-electron.1",
"resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -1900,6 +1899,7 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"cross-dirname": "^0.1.0", "cross-dirname": "^0.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
@@ -1921,6 +1921,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -1937,6 +1938,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -1951,6 +1953,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -2718,6 +2721,7 @@
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2842,6 +2846,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -2858,6 +2863,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -2874,6 +2880,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -2982,6 +2989,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -3004,6 +3012,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -3026,6 +3035,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -3111,6 +3121,7 @@
], ],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.7.0" "@emnapi/runtime": "^1.7.0"
}, },
@@ -3133,6 +3144,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -3152,6 +3164,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -3551,7 +3564,8 @@
"version": "16.0.10", "version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==", "integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.0.10", "version": "16.0.10",
@@ -3565,6 +3579,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3581,6 +3596,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3597,6 +3613,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3613,6 +3630,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3629,6 +3647,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3645,6 +3664,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3661,6 +3681,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3677,6 +3698,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3767,7 +3789,6 @@
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"playwright": "1.57.0" "playwright": "1.57.0"
}, },
@@ -5208,6 +5229,7 @@
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
@@ -5541,7 +5563,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==", "integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@tanstack/history": "1.141.0", "@tanstack/history": "1.141.0",
"@tanstack/react-store": "^0.8.0", "@tanstack/react-store": "^0.8.0",
@@ -5848,6 +5869,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": { "node_modules/@types/cors": {
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -6093,7 +6124,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -6104,7 +6134,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -6210,7 +6239,6 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.50.0",
@@ -6704,8 +6732,7 @@
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@xyflow/react": { "node_modules/@xyflow/react": {
"version": "12.10.0", "version": "12.10.0",
@@ -6803,7 +6830,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -6864,7 +6890,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -7463,7 +7488,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -7995,7 +8019,8 @@
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
@@ -8228,6 +8253,25 @@
"integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -8281,7 +8325,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true "optional": true,
"peer": true
}, },
"node_modules/cross-env": { "node_modules/cross-env": {
"version": "10.1.0", "version": "10.1.0",
@@ -8378,7 +8423,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -8680,7 +8724,6 @@
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "26.0.12", "app-builder-lib": "26.0.12",
"builder-util": "26.0.11", "builder-util": "26.0.11",
@@ -9007,6 +9050,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@electron/asar": "^3.2.1", "@electron/asar": "^3.2.1",
"debug": "^4.1.1", "debug": "^4.1.1",
@@ -9027,6 +9071,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.1.2", "graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0", "jsonfile": "^4.0.0",
@@ -9277,7 +9322,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -9592,7 +9636,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -11260,6 +11303,7 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11321,6 +11365,7 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -13748,6 +13793,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.6", "nanoid": "^3.3.6",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
@@ -13764,6 +13810,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"commander": "^9.4.0" "commander": "^9.4.0"
}, },
@@ -13781,6 +13828,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": "^12.20.0 || >=14" "node": "^12.20.0 || >=14"
} }
@@ -13969,7 +14017,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -13979,7 +14026,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -14338,6 +14384,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported", "deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.3" "glob": "^7.1.3"
}, },
@@ -14526,7 +14573,6 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", "integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
@@ -14575,6 +14621,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.0.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
@@ -14625,6 +14672,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14647,6 +14695,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14669,6 +14718,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14685,6 +14735,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14701,6 +14752,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14717,6 +14769,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14733,6 +14786,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14749,6 +14803,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14765,6 +14820,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14781,6 +14837,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14803,6 +14860,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14825,6 +14883,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14847,6 +14906,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14869,6 +14929,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14891,6 +14952,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -15359,6 +15421,7 @@
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"client-only": "0.0.1" "client-only": "0.0.1"
}, },
@@ -15528,6 +15591,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"rimraf": "~2.6.2" "rimraf": "~2.6.2"
@@ -15591,6 +15655,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"minimist": "^1.2.6" "minimist": "^1.2.6"
}, },
@@ -15688,7 +15753,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -15893,7 +15957,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -16265,7 +16328,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -16355,8 +16417,7 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/vite/node_modules/fdir": { "node_modules/vite/node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
@@ -16382,7 +16443,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -16425,7 +16485,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.16", "@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16", "@vitest/mocker": "4.0.16",
@@ -16683,7 +16742,6 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },
@@ -16752,7 +16810,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -22,6 +22,7 @@
"dev:electron:wsl": "npm run build:packages && npm run _dev:electron:wsl", "dev:electron:wsl": "npm run build:packages && npm run _dev:electron:wsl",
"dev:electron:wsl:gpu": "npm run build:packages && npm run _dev:electron:wsl:gpu", "dev:electron:wsl:gpu": "npm run build:packages && npm run _dev:electron:wsl:gpu",
"dev:server": "npm run build:packages && npm run _dev:server", "dev:server": "npm run build:packages && npm run _dev:server",
"dev:docker": "docker compose up --build",
"dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"", "dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"",
"build": "npm run build:packages && npm run build --workspace=apps/ui", "build": "npm run build:packages && npm run build --workspace=apps/ui",
"build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils", "build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",