Audit & stress test
Audit & stress test of the web edition
A full review of the web edition in September 2026, security, correctness and performance, with the fixes it produced and the numbers it measured.
Summary
The web edition was reviewed end to end on 5 September 2026: a code-level security review, a correctness review against the mobile app's rules, and a stress test with a 2,000-learner academy and up to 100 concurrent users. No data-integrity or security defect was found. The review produced nine improvements (listed below), all merged, and a set of measured limits that the deployment guide now reflects.
| Area | Result |
|---|---|
| Unit tests (ledger, credits, referrals, archive, backups, crypto, calendar) | 37 / 37 pass |
| End-to-end HTTP walkthrough of every flow | 20 / 20 steps pass |
| Type check (svelte-check) | 0 errors |
Dependency audit (npm audit --omit=dev) |
0 vulnerabilities in production dependencies |
| Load test, 1 process, 25–100 concurrent users | 0 errors, ~40 pages/s |
| Load test, 4 processes, 100 concurrent users | 0 errors, 127 pages/s |
Scope and method
- Code review of every server module: authentication, session handling, request gating, form actions, file serving, backup import/export, the SQLite layer and the Docker image.
- Rule-by-rule comparison with the mobile app's specification (balance formula, golden invariant of
final_amount, session-credit consumption, archive cascades, notification dedupe keys, referral milestones, backup envelope). - Automated checks: 37 Vitest unit tests on a scratch database, a 20-step HTTP smoke test that drives every screen through the same form actions the browser uses,
svelte-check,npm audit. - Stress test: a 2,000-learner academy seeded over HTTP, then a weighted mix of real page loads and writes from 25, 50 and 100 virtual users for 30 seconds each, on one laptop that also ran the load generator (so every number below is conservative).
Security review
| Check | Status |
|---|---|
| Authentication required for every page, file and download; first run gated to account creation | Implemented |
| Passwords hashed with scrypt (N = 16384), random salt, constant-time comparison | Implemented |
| Session tokens stored hashed (SHA-256); httpOnly, SameSite=Lax, Secure on HTTPS; 30-day sliding expiry; all sessions ended on password change | Implemented |
| Sign-in rate limit: 10 failures per address per 15 minutes | Implemented |
CSRF: SvelteKit origin check on every form post (ORIGIN / proxy headers) |
Implemented |
| Content Security Policy with per-request nonces: scripts only from the app origin, no inline scripts, no external connections | Added in this audit |
| Security headers: nosniff, frame-ancestors/X-Frame-Options, referrer policy, permissions policy, HSTS in production | Implemented |
| SQL: every statement parameterised; no user input reaches SQL text | Verified |
XSS: Svelte escapes all interpolations; no {@html} anywhere |
Verified |
| Path traversal: photo and backup file names validated and resolved inside the data directory | Verified by test |
Zip-slip on restore: entries with .., absolute paths or the device-key name are refused |
Verified by test |
| Restore validation: integrity check and schema probe before the live database is replaced; automatic rollback on failure | Verified by test |
Upload limits: 64 MB by default (MAX_UPLOAD_MB, BODY_SIZE_LIMIT) |
Implemented |
| Backup encryption: PBKDF2-SHA256 210k rounds, AES-256-CBC, HMAC-SHA256 verified before decryption; legacy v1 readable | Verified by test |
| Auto-backup password encrypted at rest with a key outside the database and outside every backup | Implemented |
| Accounts and sessions in a separate database, never inside a backup | Implemented |
| Container runs as non-root, single volume, health check | Implemented |
| Secrets never logged | Verified |
Residual notes: there is a single administrator role (no per-user permissions), no two-factor sign-in, and no built-in TLS (terminate HTTPS at the proxy, which Coolify does).
Correctness review
Every rule of the mobile app was traced to the web code and covered by a test:
- Balance = Σinvoice − Σdiscount − Σreferral − Σpayment + Σadjustment − Σrefund; voided rows count zero; per currency.
enrollment.final_amountalways equals the live ledger of that enrollment; recomputed after every insert, edit, void and unvoid; an enrollment whose rows are all voided recomputes to zero.- Manual discount never exceeds tuition (form and repository).
- Present and late consume a session credit; absent and excused do not; changing a status returns or takes the credit.
- Cancelling an enrollment voids invoice, discount and referral credit and keeps payments, so the learner becomes a creditor.
- Archive cascades tag children; restore brings back only those; restoring a class restores its archived term.
- Permanent deletion requires an archived record and writes final balances to the audit log; term and class deletion voids and detaches ledger rows.
- Referral milestones cap credit at the amount owed and are not consumed when nothing can be credited; self and circular referrals are refused.
- Notifications are deduplicated by key; low-credit alerts key on the remaining count.
- Backups round-trip byte-for-byte with the mobile format; wrong passwords and tampered bytes are rejected.
One design decision is unchanged and documented: the refund row type reduces the balance like a payment and is not created by any screen.
Improvements made during the audit
- Content Security Policy with nonces (script-src 'self').
- Learner list paginated (60 per page) with a full count; notifications paginated (50 per page).
- Dashboard lists limited to what is shown; the debtor filter rewritten from a correlated subquery to one grouped pass over the ledger.
- Attendance class list counts learners in one grouped query instead of one per class.
- Class, term and referral pages load only the learner names they display.
- Settings cached in memory for a second (14 fewer queries per request).
X-Response-Timeheader on every response for operations.- Optional multi-process mode (
WORKERS=n|auto); the scheduler runs in one worker only. - Cookie
Secureflag follows the request scheme; local runs deriveORIGINautomatically.
Stress test
Data set
| Table | Rows |
|---|---|
| Learners | 2,000 |
| Enrollments | 2,667 |
| Ledger rows | 6,325 |
| Attendance sessions / records | 480 / 32,004 |
| Notifications | 5,725 |
| Audit entries | 5,658 |
| Notes | 1,837 |
| Database size | 25 MB |
Seeding 2,000 learners, their enrollments, payments and 480 attendance sheets over HTTP took 27 seconds.
Single-request server time (median of 5)
| Page | Server ms |
|---|---|
| Dashboard | 36.6 |
| Learner list (all) | 9.3 |
| Learner list (debtors) | 23.7 |
| Learner search | 8.5 |
| Learner profile tabs | 3–6 |
| Classes | 9.4 |
| Class page | 24.1 |
| Terms | 5.9 |
| Attendance class list | 15.8 |
| Attendance sheet | 14.6 |
| Notifications | 13.5 |
| Audit log | 10.3 |
| Backup page | 5.8 |
Concurrent load, 30 seconds each
| Workers | Users | Pages/s | Errors | p50 | p95 | Server p50 | Server p95 |
|---|---|---|---|---|---|---|---|
| 1 | 25 | 40.3 | 0 | 616 ms | 805 ms | 15 ms | 68 ms |
| 1 | 50 | 39.7 | 0 | 1,225 ms | 1,564 ms | 15 ms | 69 ms |
| 1 | 100 | 41.8 | 0 | 2,328 ms | 2,864 ms | 15 ms | 70 ms |
| 4 | 100 | 127.4 | 0 | 475 ms | 1,931 ms | 18 ms | 90 ms |
The mix was 20 % dashboard, 20 % learner list with filters and search, 25 % profile tabs, 8 % classes and terms, 10 % attendance sheets, 5 % notifications, 12 % writes (notes and payments). Memory of the single process: 199 MB before the run, 330 MB after. Zero server-side errors in every run.
Reading the numbers
- With one process the server itself answers in about 15 ms at the median; the client-side latency at 100 users is queueing in front of a single core. Forty pages per second is roughly 2,400 page views per minute, far beyond a single academy's traffic.
- Four workers on the same laptop tripled throughput to 127 pages/s while the load generator competed for the same CPU. On a dedicated host, set
WORKERS=auto. - SQLite in WAL mode handled the write mix (notes, payments, attendance) with no busy errors.
Recommendations
- Deploy behind HTTPS (Coolify's proxy) and set
ORIGIN; keepTZcorrect so daily backups and "today" agree with your clock. - Set
WORKERS=autoon hosts with more than one core. - Take the app's encrypted backups regularly and snapshot the
/datavolume; test a restore once. - Keep the administrator password in a password manager; rotate it from Settings → Account when staff changes.