Skip to content
Article Getting started
☀️ Light Clean and bright
📜 Sepia Warm and vintage
🌤️ Light Gray Subtle and neutral
🔵 Light Blue Calm and serene
🌙 Dark Easy on the eyes
🌑 Dark Gray Deep and modern
🔷 Dark Blue Professional and sleek
🌿 Forest Calm and natural

Getting started

Requirements

ComponentNotes
PHP8.1 or newer, with pdo, openssl, zip, mbstring, json
DatabaseMySQL/MariaDB or SQLite — both are first-class
Web serverApache 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/sepodesk and 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) for app_url, app_base_path, api_base, assets_url or plugin_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:

FileUse
.htaccess.localdevelopment — installer reachable, looser rules
.htaccess.productionproduction — 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:

HandlePurposeEnv prefix
sp_db_authusers, API keys, roles, grantsPOPULATOR_DB_1_
sp_db_ui_registryprojects, app/UI registryPOPULATOR_DB_2_
sp_db_datamanageruser application dataPOPULATOR_DB_3_
sp_db_appsinstalled app storagePOPULATOR_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() returns APP_SECRET_KEY, which is what CryptoAES is called with throughout the connection path. Confirm nothing still reads AES_KEY before 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/*.csssepo-bundle.min.css
  • public/sepojs/core/*.js, utils/*.js, widgets/*.jssepo-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 permitted intermittently. 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.

  1. Generate the production APP_SECRET_KEY — a new one, not the local key: ``bash php -r "echo bin2hex(random_bytes(32));" ``
  2. Put it in your local .env temporarily (or pass it to whatever encryption route/CLI the project exposes — <confirm the exact tool here>).
  3. Encrypt each of the four production database passwords.
  4. 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 / fieldProduction change
sp_role_nameunchanged — roles are stable across environments
sp_mysql_hostchange if the host differs (localhost on Hostinger)
sp_mysql_userchange to the hPanel user
sp_mysql_password_encchange to the encrypted password
sp_db_driverusually 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::decrypt hands the plaintext through unchanged as a password.
  • Physical database names must be the Hostinger-prefixed ones. Logical handles (sp_db_auth and 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.

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:

  1. Open the hPanel File Manager (or connect over SFTP).
  2. Navigate to public_html/wp-content/plugins/.
  3. 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 with sepodesk/ and not a nested sepodesk/sepodesk/.
  4. In the WordPress dashboard, go to Plugins. SepoDesk now appears in the list. Activate it.
  5. 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/v2 surface. Symptom: the plugin is active, the files are present, and every page under /sepodesk is "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 database entries

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:

  1. Delete the Installer/ directory entirely. The .htaccess rule is a fallback, not the plan.
  2. Restore the RewriteRule (^|/)Installer/ - [F,L] line (uncomment it).
  3. Set INSTALLER_ENABLED=false in .env.
  4. Confirm INSTALLER_ALLOW_DROP_DATABASE=false and INSTALLER_ALLOW_RERUN=false.
  5. Confirm APP_DEBUG=false and ALLOW_PLAINTEXT_SECRETS=false.
  6. Confirm UPDATER_ALLOW_INSECURE is absent or false — it exists so local testing can fetch updates over plain HTTP.
  7. Rotate INSTALLER_KEY to a fresh value.
  8. Confirm the installer is gone: ``bash curl -I https://<domain>/wp-content/plugins/sepodesk/Installer/Run.php # expect 404 (deleted) or 403 (rule restored) ``
  9. Check the cache directory is not web-readable: ``bash curl -I https://<domain>/sepodesk/Storage/Cache/api_key_role_cache.json # expect 403 ``
  10. Confirm .env itself 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:

  1. One MySQL user with grants on all four schemas. Turns four connections per request into one. Requires schema-qualified table names.
  2. SEPO_EAGER_SYSTEM_APPS=false (the default) so only the requested app is instantiated, not every registered system app.
  3. CLIENT_MAX_CONCURRENT=2 caps how many requests one browser tab has in flight.
  4. Server-side retry with jitterDB_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

KeyNotes
APP_ENVproduction or local
IS_PRODUCTIONtrue in production
WORDPRESS_MODEtrue when running as a plugin
APP_BASE_PATHrouting base, e.g. /sepodesk
SITE_NAME, SITE_TAGLINEbranding

Secrets

KeyNotes
APP_SECRET_KEY≥32 chars. AES key for all stored secrets. Back it up
ALLOW_PLAINTEXT_SECRETSfalse in production
INSTALLER_KEYlong random; rotate after install

Databases

POPULATOR_DB_{1..4}_ prefixes, one per logical handle:

SuffixRequiredDefault
_NAMEyes
_USERyes
_PASS_ENCencryptedempty
_HOSTno127.0.0.1
_PORTno3306
_DRIVERnomysql

sp_db_auth also accepts the legacy AUTH_DB_* prefix. If both are set they must point at the same database.

Connection behaviour

KeyDefaultNotes
DB_MAX_ATTEMPTS4cap 6
DB_RETRY_BASE_MS250jittered backoff
DB_CONNECT_TIMEOUT4seconds
DB_VERIFY_CACHEDCLI onlySELECT 1 before reuse
DB_VERIFY_SCHEMAfalseprovisioning only
DB_SOCKETprobedonly used for literal localhost

Client request pacing

Rendered into window.SepoDeskConfig by the bootstrap views.

KeyDefaultClamp
CLIENT_MAX_CONCURRENT21–16
CLIENT_REQUEST_DELAY_MS1000–5000
CLIENT_BATCH_CONCURRENCY51–20
CLIENT_CIRCUIT_THRESHOLD31–20
CLIENT_CIRCUIT_RESET_MS300001000–600000
CLIENT_TIMEOUT_MS300000–300000
CLIENT_MAX_RETRIES10–5
CLIENT_CACHE_TTL300–3600

Installer

KeyProduction
INSTALLER_ENABLEDfalse except during install
INSTALLER_ALLOW_RERUNfalse — bypasses the installer/.installed sentinel
INSTALLER_ALLOW_DROP_DATABASEfalse — local only

Security and session

KeyProduction
APP_DEBUGfalse
COOKIE_SECUREtrue, site on HTTPS
COOKIE_SAMESITELax
SESSION_TIMEOUTseconds
TRUSTED_HOSTSyour real host names — do not leave open
TRUST_PROXYtrue only behind a proxy you control
UPDATER_ALLOW_INSECUREabsent or false

Feature flags

KeyNotes
SEPO_EAGER_SYSTEM_APPSfalse; true restores per-request eager system apps
SQLITE_ENABLEDdefault driver for handles with no _DRIVER
ENABLE_RESOURCE_EDITORfalse in production; also chmod 555 the definitions dir
ALLOW_CRUD_ON_STOREstore mirroring

How SpEnvLoader reads .env

Two behaviours to know before you debug a blank 500:

  • A missing key throws. SpEnvLoader::env('FOO') does not return null — it raises. Writing SpEnvLoader::env('FOO') ?? 'default' gives you dead code and a fatal error, not a default. Pass a default explicitly.
  • Booleans are normalised. true, 1, on and yes all read back as the string 'true'; false, 0, off, no as 'false'. Compare against those strings, or use SpEnvLoader::bool().
  • Consequence: SpEnvLoader::int('X', 2) throws when X=0 or X=1, because the value is normalised to 'false'/'true' before the numeric check. Read small integers from $_ENV directly after SpEnvLoader::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_KEY generated and backed up
  • [ ] Four passwords encrypted with that key
  • [ ] New INSTALLER_KEY generated
  • [ ] Plaintext of every hashed/encrypted value recorded

Files

  • [ ] .htaccess.production renamed to .htaccess
  • [ ] Installer RewriteRule commented 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.php loads (not 403)
  • [ ] Installer run, every database reporting real tables
  • [ ] Sign-in works through the WordPress bridge

Lock down

  • [ ] Installer/ directory deleted
  • [ ] RewriteRule uncommented
  • [ ] Installer/Run.php returns 403 or 404
  • [ ] INSTALLER_ENABLED=false, ALLOW_RERUN=false, ALLOW_DROP_DATABASE=false
  • [ ] INSTALLER_KEY rotated
  • [ ] APP_DEBUG=false, ALLOW_PLAINTEXT_SECRETS=false
  • [ ] UPDATER_ALLOW_INSECURE off
  • [ ] Storage/Cache/*.json returns 403
  • [ ] .env returns 403 or 404
  • [ ] CLIENT_MAX_CONCURRENT set for the host's connection cap