Architecture
Warka is a custom Next.js web application in front of a Frappe / ERPNext v15 and HRMS backend. The frontend owns the entire user experience and never talks to the database directly: every data request is proxied through Next.js to Frappe, which holds the business logic, the permission model, and the ledger. This document explains the stack, the request flow, the proxy pattern and why it exists, the whitelisted-method calling convention, session authentication, and how each module maps to its backend endpoints and frontend component.
Stack overview
| Layer | Technology | Listens on | Role |
|---|---|---|---|
| TLS and edge | Caddy (automatic HTTPS) | :80, :443 | Terminates TLS, adds security headers, reverse-proxies to the frontend. The only process exposed to the internet. |
| Web application | Next.js 16 (Node) | 127.0.0.1:3000 | Server-side rendering, the ERP UI, and the /api/* and /files/* proxy routes. |
| Application server | Frappe / ERPNext / HRMS on gunicorn (supervisor) | 127.0.0.1:8000 | Business logic, the custom whitelisted API, RBAC, background workers, and the scheduler. |
| Database | MariaDB | localhost socket | The system of record for every doctype. |
| Cache and queue | Redis | localhost | Session cache, dashboard cache, and the background job queue for payroll and other long tasks. |
| Custom app | cyber_zeb_erp (Python) | in-process with Frappe | The whitelisted API (api.py, careers.py), document hooks, and self-healing setup. |
Everything except Caddy binds to localhost, so backend calls never leave the box. See Installation and Deployment for the single-VPS topology and Operations for the service layout.
Request flow
Internet (HTTPS)
|
+-----------v-----------+
| Caddy | :443 / :80
| - terminates TLS | automatic Let's Encrypt cert
| - HSTS, nosniff, | (sslip.io host when no domain)
| X-Frame DENY, CSP |
| frame-ancestors |
| - strips Server / |
| X-Powered-By |
+-----------+-----------+
| 127.0.0.1:3000
+-----------v-----------+
| Next.js |
| - SSR of the ERP UI |
| - /api/[...path] | same-origin CSRF check,
| proxy route | rewrites Host + site header,
| - /files/[...path] | forwards session cookie
| proxy route |
+-----------+-----------+
| 127.0.0.1:8000
+-----------v-----------+
| Frappe / ERPNext / |
| HRMS (gunicorn) |
| - whitelisted API | cyber_zeb_erp.api.<method>
| - RBAC role guards | cyber_zeb_erp.careers.<method>
| - doc events / hooks |
+------+-----------+----+
| |
+------v---+ +---v-------+
| MariaDB | | Redis |
| (records)| | (cache / |
| | | queue) |
+----------+ +-----------+
A browser only ever sees Caddy and the Next.js origin. The /api/* and
/files/* paths are handled by Next.js route handlers that forward to Frappe
on 127.0.0.1:8000.
Security headers at the edge
Caddy attaches the same header set to every response and removes headers that leak the stack:
| Header | Value |
|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=() |
Content-Security-Policy | frame-ancestors 'none' |
Server, X-Powered-By | removed |
The CSP is deliberately scoped to frame-ancestors only, because Next.js relies
on inline scripts and styles that a full CSP would break. Clickjacking is still
fully blocked by both X-Frame-Options: DENY and frame-ancestors 'none'. See
Security and RBAC for the wider hardening posture.
The proxy pattern and why it exists
The browser never calls Frappe directly. Two Next.js route handlers stand in front of it:
frontend/app/api/[...path]/route.tsproxies every/api/*request (both the ERPNext REST resource API and the custom whitelisted methods).frontend/app/files/[...path]/route.tsproxies/files/*static assets, such as uploaded avatars and document attachments.
The proxy exists for four concrete reasons:
- Single origin. The UI, the API, and file downloads are all served from
one origin, so session cookies are first-party and there is no CORS surface
to configure. State-changing requests are checked against the origin by
isForgedRequestin the proxy route: a browser always attaches anOriginheader to cross-site writes, so a mismatch is rejected with403. Requests with noOrigin(curl, tests, server-to-server) pass, because cookies are not attached cross-site without a browser anyway. - Correct site routing. Frappe selects which site to serve using the HTTP
Hostheader. The proxy rewritesHostand setsX-Frappe-Site-Nameto the configured site (buildRequestHeadersinfrontend/lib/backend.ts), so the backend resolves the right site regardless of the address the browser used. - Configurable backend address. The backend URL and site name come from the
FRAPPE_BACKEND_URLandFRAPPE_SITE_NAMEenvironment variables, letting the same code point at a local bench, a WSL2 VM, or a production socket without edits. - Sanitized errors. When the backend is unreachable the proxy logs the
internal address server-side and returns a generic
502to the client, never exposing internal topology. In development it returns a more detailed hint instead.
The /files/* handler uses node:http rather than fetch, because the global
fetch (undici) silently drops a manually set Host header, which would break
static-file resolution for *.localhost sites and cause avatars to 404.
Whitelisted-method calling convention
The custom API is a set of Python functions decorated with @frappe.whitelist()
in the cyber_zeb_erp app. Frappe exposes each one at a stable URL:
POST /api/method/cyber_zeb_erp.api.create_sales_invoice
GET /api/method/cyber_zeb_erp.api.get_hr_overview
GET /api/method/cyber_zeb_erp.careers.list_openings
Every whitelisted method returns its payload wrapped in a response envelope:
{ "message": { "name": "SINV-0001", "grand_total": 1150.0, "submitted": true } }
The real return value is always under the message key. The frontend unwraps
this automatically. Methods declared with @frappe.whitelist(allow_guest=True)
(the careers portal and sign-up helpers) can be called without a session;
everything else enforces a role guard as its first line. See the
API Reference for the full endpoint catalog and the
Data Model for the doctypes behind them.
How the frontend calls it
frontend/lib/frappe.ts wraps the convention so components never build URLs by
hand. frappeCall(method, body) issues a GET when there is no body and a
POST with a JSON body otherwise, then returns data.message. The cyber
helper prefixes the app namespace:
// cyber.call("create_sales_invoice", {...}) hits
// /api/method/cyber_zeb_erp.api.create_sales_invoice and returns the message.
const res = await cyber.call("create_sales_invoice", {
customer: "Acme Corporation",
items: JSON.stringify([{ item: "Consulting", qty: 1, rate: 1000 }]),
tax_rate: 15,
});
The same file also exposes thin wrappers over ERPNext's REST resource API
(frappeList, frappeGet, frappeCreate, frappeUpdate, frappeDelete) for
plain doctype reads and writes, plus a typed FrappeError that carries the HTTP
status so components can detect a 403 and render an access-denied state.
Cookie and session authentication
Authentication is Frappe's standard session cookie (sid), issued when a user
logs in through /api/method/login. Because the browser and the API share one
origin through the proxy, the cookie is first-party and is forwarded on every
request, including the /files/* proxy so private attachments resolve for the
signed-in user. There is no token handoff and no separate auth service. Every
whitelisted method re-derives the user from frappe.session.user, rejects
Guest on protected endpoints, and checks the caller's roles against the
required role set before doing any work. The RBAC model is documented in
Security and RBAC.
Module to endpoint and component map
Each functional module is a frontend manager component talking to a group of
whitelisted endpoints (and, for list-heavy screens, the ERPNext REST resource
API directly). All custom endpoints live under the cyber_zeb_erp.api. or
cyber_zeb_erp.careers. namespace.
| Module | Key backend endpoints | Frontend component |
|---|---|---|
| HR and Payroll | get_hr_overview, get_hr_meta, get_employee_360, set_leave_status, allocate_leave, mark_attendance, set_employee_status, employee_punch, assign_salary_structure, run_payroll, get_payroll_status, submit_payroll_slips, set_expense_claim_status, submit_doc, get_lifecycle, get_boarding, toggle_boarding_activity, start_onboarding, start_offboarding, finish_boarding, delete_boarding | components/hr/hr-manager.tsx (with hr/sections/* and hr/employee-detail.tsx) |
| Finance | get_finance_overview, get_finance_accounts, create_sales_invoice, create_purchase_invoice, record_invoice_payment, create_journal_entry, get_financial_statement, get_financial_statement_pdf, get_aging, get_bank_summary | components/finance/finance-manager.tsx (with finance/sections/*) |
| Sales | create_sales_order, create_quotation, create_opportunity, get_sales_overview | components/sales/sales-manager.tsx |
| CRM | ERPNext REST resource API (Customer, Contact, Issue, Customer Group) | components/crm/crm-manager.tsx |
| Procurement | create_material_request, create_purchase_order, create_purchase_invoice, get_procurement_overview | components/procurement/procurement-manager.tsx |
| Inventory | get_inventory_overview (plus REST resource reads) | components/inventory/inventory-manager.tsx |
| Projects | get_projects_overview (plus REST resource reads) | components/projects/projects-manager.tsx |
| Documents | list_documents, list_shareable_users, share_file_with_users | components/documents/documents-manager.tsx |
| Reports | get_financial_statement, get_financial_statement_pdf, get_aging, get_bank_summary, frappe.client.get_count | components/reports/reports-hub.tsx |
| Business dashboard | get_business_dashboard | components/dashboard/overview-dashboard.tsx |
| Administration | list_users, create_user, set_user_roles, set_user_enabled, get_assignable_roles | components/admin/user-role-management.tsx |
| Account and profile | get_current_user_profile, update_current_user_profile, update_current_user_avatar, change_current_user_password | components/nav-user.tsx, components/app-shell.tsx |
| Careers (public) | cyber_zeb_erp.careers.get_site_settings, list_openings, get_opening, apply, set_careers_settings, set_opening_published | app/careers/*, frontend/lib/careers.ts |
Several managers use components/erp/kit.tsx, a shared toolkit of list shells,
create drawers, line-item editors, and status pills, so every module looks and
behaves consistently. See
Extending and Customizing for how to add a new
module that follows these conventions.
Shared, cached dashboards
The overview endpoints (get_hr_overview, get_finance_overview,
get_business_dashboard) are company-wide aggregations. Each is served through a
Redis-backed cache keyed by company, so many concurrent users trigger at most
one database computation per cache window (60 seconds). Any write that changes
the numbers (an invoice, a payment, a payroll run, an employee status change)
busts the cache so the next read recomputes. This keeps dashboards fast on a
small VPS. See Operations for scaling notes.