Architecture
SepoDesk is one PHP application serving two kinds of surface. Understanding which surface you are on explains almost every "why does it work like that" question.
The boot path
Every request enters through index.php, which hands off to the Kernel
(SepoEngine\Setup). The kernel boots configuration, the session, the service
container and the route table, in that order.
index.php
└─ Kernel (boot: config → session → services → routes)
└─ Router
└─ Middleware
└─ Controller@action
└─ DomBuilder / view / JSON
Services. All dependency-injection bindings live in one place,
serviceRegister.php. The Router resolves controller and action arguments by
reflection against that container, so a controller declares what it needs in its
constructor and receives it.
Middleware. AuthMiddleware and MiddlewareHelper between them hold the
public-route list, the wp_only category gate, and role-prefix RBAC keyed on
user_type. A route that needs no session goes on the public list; everything
else is authenticated by default.
Database. SpEngineConnector::connectNow is the binding for PDO. It loads
the environment, opens the auth connection through PdoFactory, then resolves
the caller's API key to a role, the role to a target database, and the role to
credentials — which are stored encrypted and decrypted with the AES key.
Role-to-database lookups are cached to storage/cache/.
PdoFactory::get()convertsPDOExceptioninto a plain\Exception. An upstreamcatch (\PDOException $e)will never fire. Catch\Exceptionat that boundary or unwrap it inside the factory.
Two rendering modes
Server-rendered
Documentation, authentication screens and the mobile dashboard are built on the
server with the PHP DomBuilder and sent as finished HTML.
Use this mode when the page is content, when it must survive with JavaScript disabled, when search engines need to see it, or when the device is a phone and you would rather not ship a shell it will use one window of.
Client-rendered
The desktop is a single server-rendered shell. After that first response the
browser owns the UI: AppShell mounts apps, DomBuilder (the JS one, mirroring
the PHP API) builds nodes, WidgetEngine and Widgets supply the components.
Use this mode when interaction is continuous — dragging, resizing, editing, several apps open against the same data.
Both DomBuilder implementations present the same building API on purpose. A
component's markup reads the same whether it was produced in PHP or in the
browser, which keeps the two surfaces from drifting apart.
Routing
Routes are regex-matched, support groups and per-group middleware, and dispatch
to Controller@action.
Alongside the table there is file-based routing for content. A request to
/docs/<path> resolves against content/pages/ in three steps:
- The exact slug, when it already carries an allowed extension.
- The slug plus each allowed extension, in configured order.
- An index file inside a directory of that name.
| Request | File on disk |
|---|---|
/docs | content/pages/index.md |
/docs/index | content/pages/index.md |
/docs/api/tokens | content/pages/api/tokens.md |
The page store only serves extensions on its allow-list, which is why images
belong under public/ and not next to the Markdown.
URLs and base paths
RouterSettings (SepoEngine\Core) is the single source of truth for URLs. It
is WORDPRESS_MODE-aware, detects host and scheme through TRUSTED_HOSTS and
TRUST_PROXY, and falls back to a static configuration when detection fails.
It exposes app_url, app_base_path, api_base, assets_url and
plugin_url. In plugin mode the routing root and the asset root are genuinely
different directories — resolve them separately, always from RouterSettings,
never by string-building from $_SERVER.
Views hand the resolved values to the browser as window.SepoDeskConfig,
together with the CSRF token.
The data layer
Application data moves over a single POST endpoint, /api/v2/data, served by
CoreDataApiController. Requests are routed to the owning app through
AppsConfig.php and manifest.json discovery, then instantiated by
ControllerFactory inside an AppShell.
API authentication is an api-public-key header, validated by ApiKeyModel and
rate-limited per user.
On the browser side the stack is:
your app code
└─ ApiLogic (core/sp-api-logic.js)
└─ EcmaScriptController
└─ POST /api/v2/data
EcmaScriptController handles caching, request de-duplication, batched and
chunked multi-table CRUD, a circuit breaker, and JWT attachment and refresh.
Write app code against ApiLogic, not against fetch.
Authentication
Browser sessions and API tokens are separate paths that meet at the user record.
- Browser.
AuthWebControllerrenders login, logout and password recovery.AuthSessionControllerhandles the session itself, including fingerprinting on IP and user agent, the refresh-token cookie, and the env-driven cookie flags. - API.
AuthTokenControllerissues HS256 access and refresh tokens. Refresh tokens rotate on use; presenting a rotated token revokes the whole family, on the assumption that a replay means a leak.
Password recovery uses security questions and runs through a StageExecutor
pipeline, with NotificationService sending the stage notifications.
Formulas and saved queries
DataMathEngine (SepoEngine\Core) evaluates spreadsheet-style formulas over
row arrays — SUM, AVG, MIN, MAX, COUNT, IF, SAFE_DIVIDE, and the
conditional variants.
QueryExecutionWorker consumes it to run saved query definitions: tables and
joins in definition_json, aggregates, grouping, calculations and filters in
math_config. Every query is scoped by ownership and sharing through
sp_owner_uid and sp_shared_with, and updates and deletes resolve the set of
authorised primary keys before writing anything.