type
Service
title
simple-lms
description
The school management service: one Go binary over one SQLite file, server-rendered pages, four built-in roles, and the ledger reached only through the register plugin.
tags
simple-lms, go, gin, htmx, sqlite, school
status
stable
stale_after
Fri Apr 30 2027 02:00:00 GMT+0200 (Central European Summer Time)
generated
techwriter/mimo-v2.6-flash · Sat Oct 31 2026 11:02:38 GMT+0100 (Central European Standard Time)
sources
main → cmd/server/main.go; config → config/config.go; readme → README.md; schema → internal/infrastructure/sqlite/migrations/001_schema.sql

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.

flowchart LR subgraph runtime["one process — ./server"] cfg["config.Load<br/>env vars; refuses to start without<br/>SESSION_SECRET and ADMIN_PASSWORD"] boot["sqlite.NewDB<br/>register plugin Register → Migrate → Init<br/>then the host's own migrations"] prov["provisionAccess<br/>SyncDefaults(DefaultRoles) + EnsureAdmin"] wire["repos → application services → handlers"] srv["gin :PORT<br/>templates rendered per language"] end disk[("data dir<br/>lms.db + -wal + -shm")] assets["./web<br/>templates · locales · static"] cfg --> boot --> prov --> wire --> srv boot --> disk assets --> srv

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

  1. config.Load() — environment only. Empty SESSION_SECRET or ADMIN_PASSWORD is a hard stop (config/config.go).
  2. Telemetry init (non-fatal), MkdirAll(DATA_DIR).
  3. 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.
  4. provisionAccess: SyncDefaults(rbacdomain.DefaultRoles()) (inserts missing built-in roles, re-wildcards a tampered manager, never overwrites owner edits) and EnsureAdmin — the first admin, attached to manager, hashed with bcrypt, must_change_password set.
  5. i18n.Load("web/locales") — fatal on failure.
  6. Repositories → application services → httpdelivery.New handler bundle.
  7. Purge expired sessions, then an hourly sweeper on the shutdown context.
  8. Gin engine: recovery → security headers → telemetry → language negotiation → localized HTML renderer (one compiled template set per language) → routes → ListenAndServe with read/write/idle timeouts; Shutdown with 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

See also

Rendered from simple-lms/service.md · OKF bundle · regenerate with npm run render