simple-lms — the service
One Go service behind Gin, one SQLite file, one school per deployment. It is
server-rendered (Go templates + HTMX + tom-select), ships English and Arabic,
and holds the whole school: students, enrollments, fees, payments, salaries,
attendance, marks, buses, roles and the audit log. The money ledger itself is
not host property — it lives in the nx-register plugin's re_* tables and is
reached only through the money service; see architecture
and money flows.
Who uses it
One deployment serves one school. Every person is a users row wearing one of
four built-in roles (provisioned from Go at boot, see
RBAC):
| Role | What the app is for them |
|---|---|
manager |
everything: the wildcard role, the only role that edits users, roles, settings and the ledger's period locks; meets /setup on first login |
accountant |
the money desk: payments, salaries, cash boxes (write), ledger, fees, reports (read); deliberately holds no settings/audit/users/roles |
teacher |
their classes: attendance and marks for assigned classes, students read/write at scope assigned |
parent |
their children: students, payments, attendance and marks at scope own |
The dashboard dispatches on the role code: teachers get their class rosters,
parents get their children's read-only records, everyone else gets the
school-wide metrics page (internal/interfaces/http/dashboard_handler.go).
Runtime shape
| Layer | Thing | Notes |
|---|---|---|
| Edge | Gin on :PORT (default :8080) |
trusted proxies only from TRUSTED_PROXIES; ClientIP() is not spoofable, so the login rate limiter holds |
| HTTP | internal/interfaces/http |
middleware → h.Require(resource, action) → handlers; see architecture |
| App | internal/application |
auth, rbac, access, school, student, money, settings, guardian, audit, transport |
| Store | SQLite via bun + sqliteshim |
WAL, foreign keys on, busy timeout 5 s, and SetMaxOpenConns(1) — all work serialises through one connection, which removes SQLITE_BUSY entirely rather than retrying around it |
| Ledger | nx-register plugin |
booted inside NewDB before any host migration runs; Core() == true today, so a Register/Migrate/Init failure refuses to start |
| Sessions | gorilla cookie store | signed cookie, HttpOnly, SameSite=Lax, Secure in release mode, 24 h TTL (sessionTTL), expired rows purged at boot and hourly |
| Assets | ./web on disk |
templates and locales are read at boot (a missing locale file is a fatal error); migrations are embedded in the binary, templates are not |
| Telemetry | OpenTelemetry | non-fatal on failure; stdout exporter outside release mode, OTLP endpoint from the environment |
Boot, in order
config.Load()— environment only. EmptySESSION_SECRETorADMIN_PASSWORDis a hard stop (config/config.go).- Telemetry init (non-fatal),
MkdirAll(DATA_DIR). sqlite.NewDB: DSN pragmas (WAL, FK, busy timeout), one connection, register plugin boot first (Register → Migrate → Init), then the host's embedded migrations in one transaction — a failed migration refuses to start.provisionAccess:SyncDefaults(rbacdomain.DefaultRoles())(inserts missing built-in roles, re-wildcards a tampered manager, never overwrites owner edits) andEnsureAdmin— the first admin, attached tomanager, hashed with bcrypt,must_change_passwordset.i18n.Load("web/locales")— fatal on failure.- Repositories → application services →
httpdelivery.Newhandler bundle. - Purge expired sessions, then an hourly sweeper on the shutdown context.
- Gin engine: recovery → security headers → telemetry → language negotiation →
localized HTML renderer (one compiled template set per language) → routes →
ListenAndServewith read/write/idle timeouts;Shutdownwith a 10 s budget on SIGINT/SIGTERM.
Configuration
All runtime behaviour comes from environment variables (.env.example is the
template; the deploy manifests carry the production values):
| Key | Default | Meaning |
|---|---|---|
PORT |
:8080 |
listen address |
GIN_MODE |
debug |
release in the deployed tenants |
DATA_DIR |
./data |
the only stateful directory |
DB_PATH |
./data/lms.db |
SQLite file (joined under DATA_DIR when relative) |
LANGS |
en |
comma-separated; first is default (en,ar deployed) |
SESSION_SECRET |
— | required; signs the session cookie |
ADMIN_USER / ADMIN_PASSWORD |
admin / — |
first-boot admin; password required |
SUPPORTED_CURRENCIES |
USD,SYP,TRY,EUR |
the universe the settings page may enable |
TRUSTED_PROXIES |
empty | CIDRs allowed to set X-Forwarded-For; empty trusts none |
PUBLIC_URL, OTEL_* |
— | links/redirects and telemetry |
What it refuses to be
- Not multi-tenant in one process. One school per deployment; the two tenants are two swarm services with two volumes, not two datasets in one file.
- Not an API. Every screen is a server-rendered page; there is no JSON surface for the UI and no client-side state beyond HTMX and tom-select.
- Not the owner of the ledger. No host table stores a box or a movement;
payments/payment_linescarry amovement_idlink, and every box, line, void, count and period lock goes through the plugin (money). - Not its own backup system. The image ships no backup script and no
/backupsvolume — the platform runs one volume-backup agent per volume (deployment). - Not a place for committed secrets. The manifests carry
__SESSION_SECRET__/__ADMIN_PASSWORD__placeholders and CI fails if a literal is ever committed.
See also
- Architecture — request path, package layout, the three seams.
- Data model — every table and relationship.
- Screens — routes per area with their RBAC gates.
Rendered from simple-lms/service.md · OKF bundle · regenerate with npm run render