-
Notifications
You must be signed in to change notification settings - Fork 1
Architecture
Technical architecture documentation for nself-admin v1.0.0.
-
ADR-003: admin/ Next.js Permanent Exception — admin/ stays on Next.js permanently while the rest of the nSelf ecosystem converges on React + Vite + Tauri 2 (per the 2026-05-14 Flutter-elimination directive). Server.js, Docker socket integration, LokiJS,
pgdirect connection, and the server-side socket.io emitter are all architecturally incompatible with a Vite SPA. Status: Accepted (permanent).
- System Overview
- Core Principles
- Technology Stack
- Architecture Diagrams
- Directory Structure
- Component Hierarchy
- Data Flow
- State Management
- API Design
- Real-Time Architecture
- Security Model
- Performance Optimizations
- Database Schema
- Deployment Architecture
nself-admin is a web-based UI wrapper for the nself CLI. It provides a visual interface for managing your self-hosted backend stack without reimplementing any CLI logic.
-
UI Wrapper: All operations delegate to
nselfCLI commands - Zero Footprint: Never modifies user's project except through nself CLI
- Self-Contained: Internal state stored in embedded LokiJS database
- Docker-First: Designed to run in containers with volume mounts
- Real-Time: WebSocket and SSE for live updates
Browser (User) → nself-admin (Next.js) → nself CLI → Docker → Services
↓
LokiJS Database
GOLDEN RULE: Never reimplement nself CLI logic in nself-admin.
Examples:
// ✅ CORRECT: Delegate to CLI
export async function POST() {
const result = await execFile('nself', ['start'])
return NextResponse.json({ output: result.stdout })
}
// ❌ WRONG: Reimplementing CLI logic
export async function POST() {
const composeFile = readFileSync('docker-compose.yml')
await execFile('docker-compose', ['up', '-d'])
// This reimplements nself's logic!
}Guide users through a logical flow:
- Password Setup - First-time security
- Project Initialization - 6-step wizard
- Service Build - Configuration and build
- Service Start - Launch containers
- Dashboard - Monitor and manage
- Frontend: React components, UI state
- API Routes: Business logic, CLI execution
- Database: Sessions, cache, audit logs
- CLI Executor: Isolated command execution
- Services: Background tasks (polling, WebSocket)
- Graceful Degradation: UI works without WebSocket
- Error Boundaries: Isolate component failures
- Retry Logic: Automatic retries with exponential backoff
- Health Checks: Monitor all critical services
| Technology | Version | Purpose |
|---|---|---|
| Next.js | 16.x | React framework (App Router) |
| React | 19.x | UI library |
| TypeScript | 5.9 | Type safety |
| Tailwind CSS | 4.x | Styling |
| Radix UI | Latest | Headless components |
| Zustand | 5.x | Global state |
| SWR | 2.x | Data fetching |
| Socket.io Client | 4.8 | WebSocket |
| Monaco Editor | 4.x | Code editor |
| Recharts | 3.x | Charts |
| Technology | Version | Purpose |
|---|---|---|
| Node.js | 18+ | Runtime |
| LokiJS | 1.5 | Embedded database |
| bcryptjs | 3.x | Password hashing |
| Socket.io | 4.8 | WebSocket server |
| Dockerode | 4.x | Docker API |
| Zod | 4.x | Validation |
| Technology | Version | Purpose |
|---|---|---|
| Jest | 30.x | Unit testing |
| Playwright | 1.55 | E2E testing |
| ESLint | 9.x | Linting |
| Prettier | 3.x | Formatting |
| pnpm | 10.x | Package manager |
┌─────────────────────────────────────────────────────────────────┐
│ User's Machine │
│ │
│ ┌──────────┐ │
│ │ Browser │ │
│ └────┬─────┘ │
│ │ HTTP/WebSocket (Port 3021) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ nself-admin Docker Container │ │
│ │ │ │
│ │ ┌────────────┐ ┌──────────┐ ┌───────────┐ │ │
│ │ │ Next.js │◄────►│ API │◄────►│ LokiJS │ │ │
│ │ │ Frontend │ │ Routes │ │ Database │ │ │
│ │ └────────────┘ └────┬─────┘ └───────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────┐ │ │
│ │ │ nself CLI │ │ │
│ │ │ Executor │ │ │
│ │ └──────┬───────┘ │ │
│ └───────────────────────────┼────────────────────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ │ ▼ │ │
│ ┌────▼─────┐ ┌──────────────┐ ┌────▼──────┐ │
│ │ Docker │ │ Project │ │ Docker │ │
│ │ Socket │ │ Directory │ │ Compose │ │
│ └──────────┘ │ (/workspace)│ └───────────┘ │
│ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Running Containers │ │
│ │ PostgreSQL │ Hasura │ Auth │ MinIO │ Redis ... │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌────────┐
│ User │
└───┬────┘
│
│ 1. Click "Start Services"
▼
┌───────────────┐
│ React │
│ Component │
└───┬───────────┘
│
│ 2. Call API
▼
┌───────────────┐
│ API Route │
│ /api/services │
│ /start │
└───┬───────────┘
│
│ 3. Validate session
▼
┌───────────────┐
│ Auth Check │
└───┬───────────┘
│
│ 4. Execute CLI
▼
┌───────────────┐
│ nself CLI │
│ Executor │
└───┬───────────┘
│
│ 5. Run command
▼
┌───────────────┐
│ execFile() │
│ nself start │
└───┬───────────┘
│
│ 6. Docker operations
▼
┌───────────────┐
│ Docker Engine │
└───┬───────────┘
│
│ 7. Start containers
▼
┌───────────────┐
│ Services │
│ Running │
└───────────────┘
nself-admin/
├── .claude/ # Development instructions
│ └── CLAUDE.md # Project guidelines
├── docs/ # Documentation
│ ├── ARCHITECTURE.md # This file
│ ├── API.md # API reference
│ ├── DEPLOYMENT.md # Deployment guide
│ ├── DEVELOPMENT.md # Development guide
│ ├── MIGRATION.md # Migration guides
│ └── CHANGELOG.md # Release notes
├── public/ # Static assets
├── scripts/ # Build/deployment scripts
├── src/ # Source code
│ ├── app/ # Next.js App Router
│ ├── components/ # React components
│ ├── contexts/ # React contexts
│ ├── hooks/ # Custom hooks
│ ├── lib/ # Utilities
│ └── services/ # Background services
├── tests/ # Test files
├── .env.example # Example environment
├── Dockerfile # Container definition
├── docker-compose.yml # Local development
├── jest.config.js # Jest configuration
├── next.config.mjs # Next.js configuration
├── package.json # Dependencies
├── pnpm-lock.yaml # Lock file
├── tailwind.config.ts # Tailwind configuration
└── tsconfig.json # TypeScript configuration
src/
├── app/ # Next.js App Router
│ ├── api/ # API routes (120+ endpoints)
│ │ ├── auth/ # Authentication (5 endpoints)
│ │ ├── config/ # Configuration (8 endpoints)
│ │ ├── database/ # Database (18 endpoints)
│ │ ├── deploy/ # Deployment (10 endpoints)
│ │ ├── docker/ # Docker (15 endpoints)
│ │ ├── services/ # Services (12 endpoints)
│ │ ├── cloud/ # Cloud providers (15 endpoints)
│ │ ├── k8s/ # Kubernetes (12 endpoints)
│ │ ├── monitor/ # Monitoring (8 endpoints)
│ │ ├── plugins/ # Plugins (10 endpoints)
│ │ └── system/ # System (7 endpoints)
│ ├── login/ # Login page
│ ├── build/ # Build wizard
│ ├── config/ # Configuration pages
│ ├── database/ # Database pages
│ ├── services/ # Service management
│ ├── deployment/ # Deployment pages
│ ├── cloud/ # Cloud provider pages
│ ├── plugins/ # Plugin pages
│ ├── monitor/ # Monitoring pages
│ ├── settings/ # Settings pages
│ ├── layout.tsx # Root layout
│ └── page.tsx # Dashboard
├── components/ # React components (60+)
│ ├── ui/ # Base components
│ │ ├── button.tsx
│ │ ├── input.tsx
│ │ ├── card.tsx
│ │ └── ...
│ ├── build/ # Build wizard components
│ ├── config/ # Config components
│ ├── database/ # Database components
│ ├── services/ # Service components
│ ├── skeletons/ # Loading states
│ ├── Header.tsx # Top navigation
│ ├── Layout.tsx # Main layout
│ └── Navigation.tsx # Sidebar
├── contexts/ # React contexts
│ └── AuthContext.tsx # Auth state
├── hooks/ # Custom hooks
│ ├── useDashboardData.ts # Dashboard data
│ ├── useServiceStatus.ts # Service status
│ ├── useSession.ts # Session management
│ └── useWebSocket.ts # WebSocket connection
├── lib/ # Utilities
│ ├── api-client.ts # API client
│ ├── auth-db.ts # Auth database
│ ├── database.ts # LokiJS interface
│ ├── nselfCLI.ts # CLI executor
│ ├── validation.ts # Zod schemas
│ └── utils.ts # Utilities
└── services/ # Background services
└── SimplifiedPolling.ts # Polling service
<Layout>
├── <Header>
│ ├── <Logo>
│ ├── <ThemeToggle>
│ └── <UserMenu>
├── <Navigation>
│ └── <NavItem> × N
├── <PageContent>
│ └── [Page Component]
└── <Footer>
<DashboardPage>
├── <PageHeader>
├── <ServiceGrid>
│ └── <ServiceCard> × N
│ ├── <ServiceStatus>
│ ├── <ServiceMetrics>
│ └── <ServiceActions>
├── <MetricsSection>
│ └── <MetricCard> × N
└── <ActivityFeed>
└── <ActivityItem> × N
<ConfigForm>
├── <FormSection>
│ ├── <FormField>
│ │ ├── <Label>
│ │ ├── <Input>
│ │ └── <ErrorMessage>
│ └── <FormField> × N
└── <FormActions>
├── <Button type="submit">
└── <Button type="reset">
User → Login Page → API Route → Database
↓ ↓ ↓
Password Validate Check hash
↓ ↓ ↓
Submit ────────────→ Create ──────→ Store
Session Session
↓
Set Cookie
↓
Redirect
↓
Dashboard
Dashboard → Click "Start" → API Route → CLI Executor
↓ ↓ ↓
Service Card Auth Check nself start
↓ ↓ ↓
Loading State Execute Docker Compose
↓ ↓ ↓
WebSocket Update Stream Start Containers
↓ Output ↓
Update UI ◄───────────────────────────────────┘
Container State Change
↓
Docker Event
↓
WebSocket Server
↓
Broadcast to Clients
↓
WebSocket Hook (useWebSocket)
↓
Update Zustand Store
↓
Re-render Components
// Service Status Store
interface ServiceStore {
services: Service[]
setServices: (services: Service[]) => void
updateService: (name: string, updates: Partial<Service>) => void
}
// Auth Store
interface AuthStore {
user: User | null
isAuthenticated: boolean
login: (password: string) => Promise<void>
logout: () => void
}
// UI Store
interface UIStore {
sidebarOpen: boolean
theme: 'light' | 'dark'
toggleSidebar: () => void
setTheme: (theme: 'light' | 'dark') => void
}// Data fetching with automatic caching
const { data, error, mutate } = useSWR('/api/services/status', fetcher, {
refreshInterval: 5000, // Poll every 5 seconds
revalidateOnFocus: true,
dedupingInterval: 2000,
})// Component-specific state
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)const form = useForm<ConfigFormData>({
resolver: zodResolver(configSchema),
defaultValues: { ... },
})Success:
{
"success": true,
"data": { ... }
}Error:
{
"success": false,
"error": "User-friendly message",
"details": "Technical details (dev mode only)"
}All API routes (except /api/health and /api/auth/login) require authentication.
Session Token:
- Stored in httpOnly cookie
- 7-day expiration
- Auto-renewed on activity
Validation:
export async function GET(request: Request) {
const session = await validateSession(request)
if (!session) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
// ...
}Default limits:
- Authentication: 5 requests / 15 minutes
- General API: 100 requests / 15 minutes
- WebSocket: Unlimited (connection-based)
Standard pattern:
export async function POST(request: Request) {
try {
// Validate session
const session = await validateSession(request)
if (!session) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
// Validate input
const body = await request.json()
const validated = schema.parse(body)
// Execute operation
const result = await performOperation(validated)
// Return success
return NextResponse.json({ success: true, data: result })
} catch (error) {
// Log error
console.error('Operation failed:', error)
// Return error response
return NextResponse.json(
{
success: false,
error: 'Operation failed',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}Server-side:
// src/lib/websocket/server.ts
const io = new Server(httpServer, {
cors: { origin: '*' },
transports: ['websocket', 'polling'],
})
io.on('connection', (socket) => {
console.log('Client connected:', socket.id)
// Subscribe to service updates
socket.on('subscribe:services', () => {
socket.join('services')
})
// Broadcast service updates
const broadcastUpdate = (service: Service) => {
io.to('services').emit('service:update', service)
}
})Client-side:
// src/hooks/useWebSocket.ts
export function useWebSocket() {
const [socket, setSocket] = useState<Socket | null>(null)
useEffect(() => {
const newSocket = io('http://localhost:3021')
newSocket.on('connect', () => {
console.log('WebSocket connected')
newSocket.emit('subscribe:services')
})
newSocket.on('service:update', (service) => {
updateServiceStore(service)
})
setSocket(newSocket)
return () => {
newSocket.disconnect()
}
}, [])
return socket
}For long-running operations (build, deploy):
// API route
export async function GET(request: Request) {
const stream = new TransformStream()
const writer = stream.writable.getWriter()
// Start operation
execFile('nself', ['build'], {
// Stream output
onStdout: (data) => {
writer.write(new TextEncoder().encode(`data: ${data}\n\n`))
},
})
return new Response(stream.readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}Password Security:
- bcrypt hashing (10 rounds)
- Minimum requirements (dev: 3 chars, prod: 12+ chars)
- Stored in LokiJS database (never in env vars)
Session Management:
- Secure httpOnly cookies
- 7-day expiration
- CSRF protection
- Auto-renewal on activity
Environment-Based Access:
- Dev: Access to local and dev environments
- Sr Dev: Access to local, dev, staging
- Lead Dev: Access to all environments including prod and secrets
API Protection:
- All routes require valid session (except health/login)
- Rate limiting on sensitive endpoints
- Audit logging of all actions
Zod Schemas:
const configSchema = z.object({
projectName: z.string().min(3).max(50),
port: z.number().min(1000).max(65535),
enableSSL: z.boolean(),
})Command Injection Prevention:
// ✅ SAFE: execFile with array arguments
execFile('nself', ['start', serviceName])
// ❌ UNSAFE: exec with string interpolation
exec(`nself start ${serviceName}`) // Vulnerable!Exec Isolation Policy (S45.T01, 2026-05-15):
All API routes that invoke the nself CLI must use execFile() (never exec() or spawn() with shell: true). execFile() passes arguments as an explicit argv array and never invokes a shell interpreter, eliminating shell injection at the process boundary regardless of input content.
Shared identifier validation lives in src/lib/validation/service-name.ts:
// Pattern: lowercase alphanumeric + hyphens only — no shell metacharacters
export const SERVICE_NAME_PATTERN = /^[a-z0-9-]+$/
export function validateServiceName(name: string): boolean {
if (!name) return false
return SERVICE_NAME_PATTERN.test(name)
}Every route that accepts a user-supplied identifier (service name, environment name, compare target) must validate it through this shared function before including it in an argv array. Template values must be validated against an explicit allowlist. GET handlers that would trigger mutations must return 405 (Method Not Allowed).
- Secrets encrypted in database
- Environment variables validated
- Sensitive data masked in logs
- Audit trail for all changes
Route-based splitting:
// Automatic with Next.js App Router
// Each page is a separate bundleComponent splitting:
const MonacoEditor = dynamic(() => import('@monaco-editor/react'), {
ssr: false,
loading: () => <CodeEditorSkeleton />,
})SWR caching:
const { data } = useSWR('/api/services', fetcher, {
revalidateOnFocus: false,
dedupingInterval: 5000,
})API response caching:
export const revalidate = 30 // Cache for 30 secondsFor large lists:
import { useVirtualizer } from '@tanstack/react-virtual'
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
})import Image from 'next/image'
<Image
src="/logo.png"
width={200}
height={50}
alt="Logo"
priority
/>const expensiveValue = useMemo(() => {
return computeExpensiveValue(data)
}, [data])
const memoizedCallback = useCallback(() => {
doSomething(value)
}, [value])config:
{
key: string // Unique key
value: any // Value (JSON)
updatedAt: number // Timestamp
}sessions:
{
token: string // Session token
userId: string // User ID (always 'admin' for now)
expiresAt: number // Expiration timestamp
ip: string // Client IP
userAgent: string // User agent
createdAt: number
}audit_log:
{
action: string // Action type
userId: string // Who performed it
timestamp: number
success: boolean
details: any // Action-specific data
ip: string
}project_cache:
{
key: string // Cache key
value: any // Cached data
cachedAt: number // When cached
ttl: number // Time to live (ms)
}Base image: node:18-alpine
Ports:
- 3021: Web UI
Volumes:
-
/workspace: User's project (read-write) -
/var/run/docker.sock: Docker socket (read-write) -
/app/data: nAdmin database (persistent)
Environment:
NSELF_PROJECT_PATH=/workspaceNODE_ENV=productionPORT=3021
Internet
↓
[Reverse Proxy: Nginx/Caddy]
↓ HTTPS (443)
[nself-admin Container]
↓
[Docker Socket] → [Services]
↓
[Project Directory]
┌─────────────────┐
│ Load Balancer │
└────┬────────┬───┘
│ │
┌──────▼──┐ ┌─▼────────┐
│ nAdmin │ │ nAdmin │
│ Instance│ │ Instance │
│ 1 │ │ 2 │
└──┬──────┘ └────┬─────┘
│ │
└──────┬───────┘
│
┌──────▼──────┐
│ Shared │
│ Session │
│ Storage │
└─────────────┘
- Initial Load: < 1.5s
- Time to Interactive: < 2s
- API Response: < 100ms (average)
- Real-Time Latency: < 50ms
- Lighthouse Score: 95+
// Custom performance monitoring
performance.mark('api-call-start')
await apiCall()
performance.mark('api-call-end')
performance.measure('api-call', 'api-call-start', 'api-call-end')- Multi-User Support: User roles, permissions
- Distributed Sessions: Redis-backed sessions
- Horizontal Scaling: Multiple instances
- Advanced Caching: Redis cache layer
- Metrics Export: Prometheus metrics
- Plugin System: Extensible architecture
- API Documentation
- Deployment Guide
- Development Guide
- nself CLI Documentation
- Security Advisory 2026-05-15 — RCE via shell injection in deployment environments endpoint (Critical, fixed v1.2.0)
Questions? Open an issue on GitHub.
Version: 1.0.0 | Updated: 2026-09-14 12:29 UTC | GitHub