Getting started
Requirements
| Component | Notes |
|---|---|
| PHP | 8.1 or newer, with pdo, openssl, zip, mbstring, json |
| Database | MySQL/MariaDB or SQLite — both are first-class |
| Web server | Apache with mod_rewrite, or Nginx with a front-controller rule |
SepoDesk runs in two deployment shapes, and the difference matters for URLs:
- Standalone — the project sits at a domain root or a sub-folder, e.g.
localhost/sepodesk. - WordPress plugin — the project lives at
wp-content/plugins/sepodeskand is routed under/sepodesk. Here the routing base and the asset base are different roots.
WORDPRESS_MODE in the environment selects between them. index.php doubles
as the plugin entry point, so the same tree serves both.
Never hard-code a URL in a view or a JS module. Ask
RouterSettings(SepoEngine\Core) forapp_url,app_base_path,api_base,assets_urlorplugin_url. It is the only component that knows which shape you are in.
Part 1 — Local install
Do this first even when your target is production. The encryption step needs a working local engine, and it is much cheaper to get the layout right on a machine where you can read errors.
1. Place the files
Copy the project into the web root for standalone, or into
wp-content/plugins/sepodesk for plugin mode. Point the document root at the
project and confirm the rewrite rule sends everything that is not a real file
to index.php.
2. Choose the local .htaccess
The project ships two:
| File | Use |
|---|---|
.htaccess.local | development — installer reachable, looser rules |
.htaccess.production | production — installer blocked, hardened |
Locally, rename .htaccess.local → .htaccess. Keep the production copy
under its own name so it is not accidentally live.
3. Create the databases
SepoDesk uses four logical databases. Locally create them under whatever names you like — the mapping is by logical handle, not physical name:
| Handle | Purpose | Env prefix |
|---|---|---|
sp_db_auth | users, API keys, roles, grants | POPULATOR_DB_1_ |
sp_db_ui_registry | projects, app/UI registry | POPULATOR_DB_2_ |
sp_db_datamanager | user application data | POPULATOR_DB_3_ |
sp_db_apps | installed app storage | POPULATOR_DB_4_ |
On XAMPP the usual local setup is root with an empty password.
4. Write .env
See the environment reference below for the full list. A minimum viable local file:
APP_ENV="local"
IS_PRODUCTION=false
WORDPRESS_MODE=false
APP_BASE_PATH="/sepodesk"
SITE_NAME="SEPODESK"
SITE_TAGLINE="Serve>Master>Improve"
APP_SECRET_KEY= # see step 5
ALLOW_PLAINTEXT_SECRETS=true # local only
POPULATOR_DB_1_NAME=sepo_auth
POPULATOR_DB_1_USER=root
POPULATOR_DB_1_PASS_ENC=
POPULATOR_DB_2_NAME=sepo_ui
POPULATOR_DB_2_USER=root
POPULATOR_DB_2_PASS_ENC=
POPULATOR_DB_3_NAME=sepo_data
POPULATOR_DB_3_USER=root
POPULATOR_DB_3_PASS_ENC=
POPULATOR_DB_4_NAME=sepo_apps
POPULATOR_DB_4_USER=root
POPULATOR_DB_4_PASS_ENC=
INSTALLER_ENABLED=true
INSTALLER_ALLOW_RERUN=true
INSTALLER_ALLOW_DROP_DATABASE=true # local only — never in production
INSTALLER_KEY= # long random value
5. Generate APP_SECRET_KEY
php -r "echo bin2hex(random_bytes(32));"
This is the AES key for every stored secret — database passwords in .env and
the per-role credentials in the roles map. Back it up somewhere outside the
project.
CryptoAES::decrypt() returns its input unchanged when the key is wrong
instead of failing loudly, so a lost or changed key does not raise a crypto
error — it produces garbled credentials that surface as MySQL "Access denied".
Older documentation referred to this as
AES_KEY.SpEnvLoader::aesKey()returnsAPP_SECRET_KEY, which is whatCryptoAESis called with throughout the connection path. Confirm nothing still readsAES_KEYbefore removing it from an existing.env.
6. Prepare the migration folders
Migration/Versions/ holds one directory per database, named for the
physical database name, containing table schemas and seed data:
Migration/
├── Versions/
│ ├── sepo_auth/
│ │ └── tsp_sys_users/
│ │ ├── <versionId>.php
│ │ ├── .versionMap.json
│ │ └── .active
│ ├── sepo_ui/
│ ├── sepo_data/
│ └── sepo_apps/
├── Generated/Tables/
└── Data/
The directory names must match your database names exactly. This is the step most installs get wrong — see Renaming migration folders in the production section.
7. Run the installer
Visit the installer route and supply INSTALLER_KEY. It creates the databases
and applies table versions from Migration/Versions, journalling each action
so you can see which version was applied and when.
8. Build the front end
The bundler is a PHP script in the project — there is no Node step. It concatenates:
public/style/*.css→sepo-bundle.min.csspublic/sepojs/core/*.js,utils/*.js,widgets/*.js→sepo-core.min.js
import statements are stripped during concatenation, and the bundle exposes
real exports so views can do:
<script type="module">
import { AppShell, DomBuilder, Widgets } from "<assets>/sepojs/sepo-core.min.js";
</script>
Run the build after every front-end change. Serving stale bundles is the single most common cause of "my change did nothing".
9. Sign in
Sign in at /login and change the seeded admin credentials.
Part 2 — Production deployment (Hostinger)
Read this section end to end before starting. Several steps depend on ones after them, and two of them are irreversible in practice (the AES key and the roles map).
1. Create the databases and users in hPanel
Do this before touching any files. Shared hosting will not let the installer create databases or grant users — you must create each one in the control panel first.
For each of the four logical databases, create:
- a database (Hostinger prefixes it, e.g.
u576451534_sp_db_auth) - a user (e.g.
u576451534_sp_euneoauth) - a strong password
Record all twelve values. You need them for the next step and for the roles map.
Connection budget. Four databases with four separate users means four MySQL logins per request. Shared plans cap concurrent connections per user and exceeding it produces
SQLSTATE[HY000] [2002] Operation not permittedintermittently. If you can grant a single user access to all four schemas, do — it is the single biggest reliability win. Otherwise see Connection limits below.
2. Encrypt the passwords locally
Production .env and the roles map store encrypted passwords, never
plaintext. Encryption uses CryptoAES with the production APP_SECRET_KEY.
- Generate the production
APP_SECRET_KEY— a new one, not the local key: ``bash php -r "echo bin2hex(random_bytes(32));"`` - Put it in your local
.envtemporarily (or pass it to whatever encryption route/CLI the project exposes —<confirm the exact tool here>). - Encrypt each of the four production database passwords.
- Record each ciphertext against its database.
Keep a secure record of both the plaintext passwords and the key. You cannot recover a plaintext from a ciphertext without the key, and you cannot recover the key at all.
3. Fill in the production .env
APP_BASE_PATH="/sepodesk"
APP_ENV="production"
IS_PRODUCTION=true
WORDPRESS_MODE=true
SITE_NAME="SEPODESK"
SITE_TAGLINE="Serve>Master>Improve"
APP_SECRET_KEY=<the new production key>
ALLOW_PLAINTEXT_SECRETS=false
APP_DEBUG=false
POPULATOR_DB_1_NAME=u576451534_sp_db_auth
POPULATOR_DB_1_USER=u576451534_sp_euneoauth
POPULATOR_DB_1_PASS_ENC=<ciphertext>
POPULATOR_DB_1_HOST=localhost
# ... repeat for 2, 3, 4
INSTALLER_ENABLED=true # temporarily, for the install only
INSTALLER_ALLOW_RERUN=false
INSTALLER_ALLOW_DROP_DATABASE=false
INSTALLER_KEY=<a new long random value>
Generate a fresh INSTALLER_KEY for production. Never reuse the local one.
The installer can rewrite your schema; the key is the only thing standing in
front of it while INSTALLER_ENABLED=true.
APP_DEBUG must be off. The debug branch in SpEngineConnector::message()
appends the full exception chain — hosts, usernames, schema names, file paths —
into the HTTP response body.
4. Update the role credentials map
The roles map table (tsp_sys_core_roles_map) tells the engine which
credentials to use for which role, and tsp_sys_role_db_access maps roles to
physical database names.
What changes for production, and what does not:
| Column / field | Production change |
|---|---|
sp_role_name | unchanged — roles are stable across environments |
sp_mysql_host | change if the host differs (localhost on Hostinger) |
sp_mysql_user | change to the hPanel user |
sp_mysql_password_enc | change to the encrypted password |
sp_db_driver | usually unchanged (mysql) |
sp_target_db_name (access table) | change to the prefixed physical database name |
Two traps here:
- The password column stores ciphertext. Putting a plaintext value in it fails
with "Access denied", because
CryptoAES::decrypthands the plaintext through unchanged as a password. - Physical database names must be the Hostinger-prefixed ones. Logical handles
(
sp_db_authand friends) stay the same — only the physical name changes.
After editing either table, invalidate the caches or the old mapping keeps being served for up to an hour:
RoleDbAccessCache::clear();
ApiKeyRoleCache::clear();
5. Rename the migration folders
Migration/Versions/<dbname>/ directories are keyed on the physical
database name. Local names will not match production, so rename each:
Migration/Versions/sepo_auth/ → Migration/Versions/u576451534_sp_db_auth/
Migration/Versions/sepo_ui/ → Migration/Versions/u576451534_sp_db_ui/
Migration/Versions/sepo_data/ → Migration/Versions/u576451534_sp_datamanager/
Migration/Versions/sepo_apps/ → Migration/Versions/u576451534_sp_apps/
If the names do not match, the installer finds no versions for the database and installs nothing — silently, reporting success with an empty table list.
6. Match the system user to the WordPress account
In plugin mode the SepoDesk system user must correspond to the WordPress account that will sign in — the same login identifier (email or username) and the same password. If they diverge, the WordPress bridge completes its handshake but the engine has no matching identity and the session sync fails.
The system user's password is stored hashed in .env
(<confirm the exact variable name>). Because it is hashed you cannot read it
back, so:
Record the plaintext of every hashed or encrypted value before you deploy.
That means the system user password, the four database passwords, the
APP_SECRET_KEY and the INSTALLER_KEY. There is no recovery path for any of
them.
7. Unblock the installer in .htaccess
Rename .htaccess.production → .htaccess, then find this block and comment
out the RewriteRule:
# --- Installer -------------------------------------------
# The directory should be DELETED in production. This is the
# fallback for when it is not: nothing under installer/ is
# reachable at all.
# RewriteRule (^|/)Installer/ - [F,L]
Without commenting it out the installer page returns 403 Forbidden. Restore this line the moment the install finishes — step 10.
8. Upload, activate, and refresh permalinks
Do not install SepoDesk through the WordPress plugin uploader. The Plugins → Add New → Upload flow will not work: it rejects or mangles the package, and it does not preserve the directory structure the engine needs.
Instead:
- Open the hPanel File Manager (or connect over SFTP).
- Navigate to
public_html/wp-content/plugins/. - Upload the project there so it lands as
wp-content/plugins/sepodesk/. Uploading a zip and extracting in place is fine — just confirm you end up withsepodesk/and not a nestedsepodesk/sepodesk/. - In the WordPress dashboard, go to Plugins. SepoDesk now appears in the list. Activate it.
- Go to Settings → Permalinks and click Save Changes.
The permalink refresh is not optional. WordPress caches its rewrite rules, and SepoDesk registers its own routes on activation. Skip this and every SepoDesk URL returns a WordPress 404 — including the dashboard and the whole
/api/v2surface. Symptom: the plugin is active, the files are present, and every page under/sepodeskis "Page not found".
Re-save permalinks any time you change routing, APP_BASE_PATH, or
WORDPRESS_MODE.
9. Run the installer
The installer is a direct file, not a route:
https://<your-site>/wp-content/plugins/sepodesk/Installer/Run.php
That is why the RewriteRule in step 7 blocks it with 403 rather than 404 —
Apache refuses the path before PHP ever runs. If you get a 403 here, the rule
is still active; go back and comment it out.
Supply the production INSTALLER_KEY and run it. Watch for:
- every database reporting a real table count, not
empty - no
Failed scanning databaseentries
If every database comes back empty, the migration folder names do not match — go back to step 5.
10. Build and verify
Run the front-end build, then load the site. If the desktop loads but no app
opens, check window.SepoDeskConfig in the console first — a wrong api_base
is the usual culprit.
11. Lock it down
This is the step people forget. In order:
- Delete the
Installer/directory entirely. The.htaccessrule is a fallback, not the plan. - Restore the
RewriteRule (^|/)Installer/ - [F,L]line (uncomment it). - Set
INSTALLER_ENABLED=falsein.env. - Confirm
INSTALLER_ALLOW_DROP_DATABASE=falseandINSTALLER_ALLOW_RERUN=false. - Confirm
APP_DEBUG=falseandALLOW_PLAINTEXT_SECRETS=false. - Confirm
UPDATER_ALLOW_INSECUREis absent orfalse— it exists so local testing can fetch updates over plain HTTP. - Rotate
INSTALLER_KEYto a fresh value. - Confirm the installer is gone:
``
bash curl -I https://<domain>/wp-content/plugins/sepodesk/Installer/Run.php # expect 404 (deleted) or 403 (rule restored)`` - Check the cache directory is not web-readable:
``
bash curl -I https://<domain>/sepodesk/Storage/Cache/api_key_role_cache.json # expect 403`` - Confirm
.envitself is not served: ``bash curl -I https://<domain>/sepodesk/.env # expect 403 or 404``
Connection limits on shared hosting
Hostinger caps concurrent MySQL connections per user. The ceiling you hit is:
concurrent requests x distinct MySQL users touched per request
Exceeding it gives SQLSTATE[HY000] [2002] Operation not permitted on some
requests while others succeed. It is not a socket-versus-TCP problem — the
giveaway is that the same credentials connect fine on retry a moment later.
Mitigations, in order of effect:
- One MySQL user with grants on all four schemas. Turns four connections per request into one. Requires schema-qualified table names.
SEPO_EAGER_SYSTEM_APPS=false(the default) so only the requested app is instantiated, not every registered system app.CLIENT_MAX_CONCURRENT=2caps how many requests one browser tab has in flight.- Server-side retry with jitter —
DB_MAX_ATTEMPTS,DB_RETRY_BASE_MS.
Diagnostics:
DbConnectionManager::distinctLoginCount(); // your multiplier
PdoFactory::poolSize(); // connections open right now
DbConnectionManager::contentionEvents(); // retries caused by contention
Payments
PAWAPAY_API_TOKEN requires a pawaPay account; create one and copy the token
from their dashboard.
The direct mobile-money integrations — Airtel, MTN, Zamtel — are in beta and
not approved for live use. Do not set mobile:true for them. Configure those
methods as manual in _payments.md instead, so the operator confirms payment
out of band rather than the engine calling the provider.
Environment reference
Identity and mode
| Key | Notes |
|---|---|
APP_ENV | production or local |
IS_PRODUCTION | true in production |
WORDPRESS_MODE | true when running as a plugin |
APP_BASE_PATH | routing base, e.g. /sepodesk |
SITE_NAME, SITE_TAGLINE | branding |
Secrets
| Key | Notes |
|---|---|
APP_SECRET_KEY | ≥32 chars. AES key for all stored secrets. Back it up |
ALLOW_PLAINTEXT_SECRETS | false in production |
INSTALLER_KEY | long random; rotate after install |
Databases
POPULATOR_DB_{1..4}_ prefixes, one per logical handle:
| Suffix | Required | Default |
|---|---|---|
_NAME | yes | — |
_USER | yes | — |
_PASS_ENC | encrypted | empty |
_HOST | no | 127.0.0.1 |
_PORT | no | 3306 |
_DRIVER | no | mysql |
sp_db_auth also accepts the legacy AUTH_DB_* prefix. If both are set they
must point at the same database.
Connection behaviour
| Key | Default | Notes |
|---|---|---|
DB_MAX_ATTEMPTS | 4 | cap 6 |
DB_RETRY_BASE_MS | 250 | jittered backoff |
DB_CONNECT_TIMEOUT | 4 | seconds |
DB_VERIFY_CACHED | CLI only | SELECT 1 before reuse |
DB_VERIFY_SCHEMA | false | provisioning only |
DB_SOCKET | probed | only used for literal localhost |
Client request pacing
Rendered into window.SepoDeskConfig by the bootstrap views.
| Key | Default | Clamp |
|---|---|---|
CLIENT_MAX_CONCURRENT | 2 | 1–16 |
CLIENT_REQUEST_DELAY_MS | 100 | 0–5000 |
CLIENT_BATCH_CONCURRENCY | 5 | 1–20 |
CLIENT_CIRCUIT_THRESHOLD | 3 | 1–20 |
CLIENT_CIRCUIT_RESET_MS | 30000 | 1000–600000 |
CLIENT_TIMEOUT_MS | 30000 | 0–300000 |
CLIENT_MAX_RETRIES | 1 | 0–5 |
CLIENT_CACHE_TTL | 30 | 0–3600 |
Installer
| Key | Production |
|---|---|
INSTALLER_ENABLED | false except during install |
INSTALLER_ALLOW_RERUN | false — bypasses the installer/.installed sentinel |
INSTALLER_ALLOW_DROP_DATABASE | false — local only |
Security and session
| Key | Production |
|---|---|
APP_DEBUG | false |
COOKIE_SECURE | true, site on HTTPS |
COOKIE_SAMESITE | Lax |
SESSION_TIMEOUT | seconds |
TRUSTED_HOSTS | your real host names — do not leave open |
TRUST_PROXY | true only behind a proxy you control |
UPDATER_ALLOW_INSECURE | absent or false |
Feature flags
| Key | Notes |
|---|---|
SEPO_EAGER_SYSTEM_APPS | false; true restores per-request eager system apps |
SQLITE_ENABLED | default driver for handles with no _DRIVER |
ENABLE_RESOURCE_EDITOR | false in production; also chmod 555 the definitions dir |
ALLOW_CRUD_ON_STORE | store mirroring |
How SpEnvLoader reads .env
Two behaviours to know before you debug a blank 500:
- A missing key throws.
SpEnvLoader::env('FOO')does not returnnull— it raises. WritingSpEnvLoader::env('FOO') ?? 'default'gives you dead code and a fatal error, not a default. Pass a default explicitly. - Booleans are normalised.
true,1,onandyesall read back as the string'true';false,0,off,noas'false'. Compare against those strings, or useSpEnvLoader::bool(). - Consequence:
SpEnvLoader::int('X', 2)throws whenX=0orX=1, because the value is normalised to'false'/'true'before the numeric check. Read small integers from$_ENVdirectly afterSpEnvLoader::init().
SpEnvLoader::secret() decrypts; SpEnvLoader::string() does not. Exactly one
component in a chain may decrypt a value — config arrays carry ciphertext and
DbSwitcher decrypts at the end.
Database and migrations
The installer subsystem (InstallerService in App/Models, driven by
InstallerServiceController) creates databases and installs or rolls back
table versions from Migration/Versions. Every action is gated by
INSTALLER_KEY and journalled under Migration/journal, so you can see exactly
which version was applied and when.
Application tables are declared by the apps themselves in schema.json and
applied by the engine's uploadSchema, which diffs the declaration against the
live database and issues CREATE/ALTER as needed. A pre-flight guard
validates every identifier, type, index and foreign key before any DDL runs, and
fails closed on anything it does not recognise.
Deployment checklist
Print this.
Before you start
- [ ] Four databases + users + passwords created in hPanel
- [ ] All twelve values recorded somewhere safe
- [ ] New
APP_SECRET_KEYgenerated and backed up - [ ] Four passwords encrypted with that key
- [ ] New
INSTALLER_KEYgenerated - [ ] Plaintext of every hashed/encrypted value recorded
Files
- [ ]
.htaccess.productionrenamed to.htaccess - [ ] Installer
RewriteRulecommented out - [ ]
Migration/Versions/*folders renamed to physical database names - [ ] Front-end bundles built
Data
- [ ] Roles map: host, user, encrypted password updated; role names unchanged
- [ ] Access table: physical database names updated
- [ ] System user matches the WordPress account login and password
- [ ] Role and API-key caches cleared
WordPress (plugin mode)
- [ ] Uploaded via File Manager / SFTP to
wp-content/plugins/sepodesk/— not the WordPress plugin uploader - [ ] No nested
sepodesk/sepodesk/directory - [ ] Plugin activated in the WordPress dashboard
- [ ] Settings → Permalinks → Save Changes
Run
- [ ]
INSTALLER_ENABLED=true - [ ]
/wp-content/plugins/sepodesk/Installer/Run.phploads (not 403) - [ ] Installer run, every database reporting real tables
- [ ] Sign-in works through the WordPress bridge
Lock down
- [ ]
Installer/directory deleted - [ ]
RewriteRuleuncommented - [ ]
Installer/Run.phpreturns 403 or 404 - [ ]
INSTALLER_ENABLED=false,ALLOW_RERUN=false,ALLOW_DROP_DATABASE=false - [ ]
INSTALLER_KEYrotated - [ ]
APP_DEBUG=false,ALLOW_PLAINTEXT_SECRETS=false - [ ]
UPDATER_ALLOW_INSECUREoff - [ ]
Storage/Cache/*.jsonreturns 403 - [ ]
.envreturns 403 or 404 - [ ]
CLIENT_MAX_CONCURRENTset for the host's connection cap