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

LayerTechnologyListens onRole
TLS and edgeCaddy (automatic HTTPS):80, :443Terminates TLS, adds security headers, reverse-proxies to the frontend. The only process exposed to the internet.
Web applicationNext.js 16 (Node)127.0.0.1:3000Server-side rendering, the ERP UI, and the /api/* and /files/* proxy routes.
Application serverFrappe / ERPNext / HRMS on gunicorn (supervisor)127.0.0.1:8000Business logic, the custom whitelisted API, RBAC, background workers, and the scheduler.
DatabaseMariaDBlocalhost socketThe system of record for every doctype.
Cache and queueRedislocalhostSession cache, dashboard cache, and the background job queue for payroll and other long tasks.
Custom appcyber_zeb_erp (Python)in-process with FrappeThe 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:

HeaderValue
Strict-Transport-Securitymax-age=31536000; includeSubDomains
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policycamera=(), microphone=(), geolocation=()
Content-Security-Policyframe-ancestors 'none'
Server, X-Powered-Byremoved

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.ts proxies every /api/* request (both the ERPNext REST resource API and the custom whitelisted methods).
  • frontend/app/files/[...path]/route.ts proxies /files/* static assets, such as uploaded avatars and document attachments.

The proxy exists for four concrete reasons:

  1. 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 isForgedRequest in the proxy route: a browser always attaches an Origin header to cross-site writes, so a mismatch is rejected with 403. Requests with no Origin (curl, tests, server-to-server) pass, because cookies are not attached cross-site without a browser anyway.
  2. Correct site routing. Frappe selects which site to serve using the HTTP Host header. The proxy rewrites Host and sets X-Frappe-Site-Name to the configured site (buildRequestHeaders in frontend/lib/backend.ts), so the backend resolves the right site regardless of the address the browser used.
  3. Configurable backend address. The backend URL and site name come from the FRAPPE_BACKEND_URL and FRAPPE_SITE_NAME environment variables, letting the same code point at a local bench, a WSL2 VM, or a production socket without edits.
  4. Sanitized errors. When the backend is unreachable the proxy logs the internal address server-side and returns a generic 502 to 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.

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.

ModuleKey backend endpointsFrontend component
HR and Payrollget_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_boardingcomponents/hr/hr-manager.tsx (with hr/sections/* and hr/employee-detail.tsx)
Financeget_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_summarycomponents/finance/finance-manager.tsx (with finance/sections/*)
Salescreate_sales_order, create_quotation, create_opportunity, get_sales_overviewcomponents/sales/sales-manager.tsx
CRMERPNext REST resource API (Customer, Contact, Issue, Customer Group)components/crm/crm-manager.tsx
Procurementcreate_material_request, create_purchase_order, create_purchase_invoice, get_procurement_overviewcomponents/procurement/procurement-manager.tsx
Inventoryget_inventory_overview (plus REST resource reads)components/inventory/inventory-manager.tsx
Projectsget_projects_overview (plus REST resource reads)components/projects/projects-manager.tsx
Documentslist_documents, list_shareable_users, share_file_with_userscomponents/documents/documents-manager.tsx
Reportsget_financial_statement, get_financial_statement_pdf, get_aging, get_bank_summary, frappe.client.get_countcomponents/reports/reports-hub.tsx
Business dashboardget_business_dashboardcomponents/dashboard/overview-dashboard.tsx
Administrationlist_users, create_user, set_user_roles, set_user_enabled, get_assignable_rolescomponents/admin/user-role-management.tsx
Account and profileget_current_user_profile, update_current_user_profile, update_current_user_avatar, change_current_user_passwordcomponents/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_publishedapp/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.