Academy Manager

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_amount always 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

  1. Content Security Policy with nonces (script-src 'self').
  2. Learner list paginated (60 per page) with a full count; notifications paginated (50 per page).
  3. Dashboard lists limited to what is shown; the debtor filter rewritten from a correlated subquery to one grouped pass over the ledger.
  4. Attendance class list counts learners in one grouped query instead of one per class.
  5. Class, term and referral pages load only the learner names they display.
  6. Settings cached in memory for a second (14 fewer queries per request).
  7. X-Response-Time header on every response for operations.
  8. Optional multi-process mode (WORKERS=n|auto); the scheduler runs in one worker only.
  9. Cookie Secure flag follows the request scheme; local runs derive ORIGIN automatically.

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; keep TZ correct so daily backups and "today" agree with your clock.
  • Set WORKERS=auto on hosts with more than one core.
  • Take the app's encrypted backups regularly and snapshot the /data volume; test a restore once.
  • Keep the administrator password in a password manager; rotate it from Settings → Account when staff changes.