This document describes what is actually implemented in this codebase. It is
for internal admin, developer, and preview use only. Do not distribute this
document to customers : see README-product.md for the public-facing
version.
frontend (static HTML/CSS/vanilla JS)
|
v
backend API (Node.js / Express)
|
v
SQLite (development/prototype) : swappable to PostgreSQL for production
The frontend never talks to the database directly and never makes an authorization decision that matters : every permission, tenant-ownership, and subscription check is enforced again on the server, on every request. Frontend role checks exist purely to route the person to the right page and avoid a flash of the wrong UI; a server response of 401/402/403 is what actually protects data.
app/
backend/
src/
app.js Express app assembly (middleware, routes)
server.js Process entry point
config/permissions.js Single source of truth for roles & permissions
db/
connection.js better-sqlite3 connection singleton
migrate.js Migration runner
migrations/001_init.sql Full schema
seed.js Roles/permissions + bootstrap admin + demo data
middleware/
auth.js Session -> req.user, requireAuth/requirePermission/requireRole
tenant.js Session -> req.school/req.schoolId, subscription enforcement
rateLimits.js express-rate-limit configs
errorHandler.js Central error handler (never leaks stack traces)
services/
authService.js Login attempt logic, lockout, MFA check
auditService.js Audit log + security event writer
subscriptionService.js Authoritative subscription status calculation
routes/
authRoutes.js Login, logout, /me, password reset, MFA setup
onboardingRoutes.js Trial signup, staff invitations
adminRoutes.js Platform Admin routes (schools, vouchers, security, incidents)
schoolRoutes.js School-tenant routes (students, staff, fees, expenses, ...)
dashboardRoutes.js Per-role dashboard summary/chart data
utils/ ids, passwords, validation helpers
jobs/subscriptionSweep.js Hourly subscription status sweep
tests/ Integration test suite (node --test)
.env.example
package.json
frontend/
public/
index.html Login / start-trial page (preserved visual identity)
accept-invitation.html
reset-password.html
css/ (auth.css is the original template, untouched; dashboard.css and
auth-overrides.css are additive)
js/ (api.js, dashboard-shell.js, auth.js, and one file per dashboard)
pages/dashboards/
admin.html, sed.html, head-teacher.html, accountant.html
docs/
README-product.md Public documentation
README-internal.md This file
- Passwords are hashed with bcrypt (12 rounds) via
src/utils/passwords.js. This choice is a pragmatic one for a sandboxed/portable prototype with no guaranteed native build toolchain. For a real production deployment, switch to Argon2id (theargon2package): replacehash()/verify()in that file, and re-hash any existing bcrypt hash the next time that user logs in successfully. - Sessions are server-side (
express-session), identified by an httpOnly, SameSite=Lax cookie.COOKIE_SECURE=truemust be set in production (requires HTTPS). - Session IDs are regenerated on every successful login to prevent session fixation.
- Failed logins increment a per-account counter; 5 failures locks the
account for 15 minutes (
src/services/authService.js). This is on top of a per-IP rate limiter on/api/auth/login(src/middleware/rateLimits.js). - Password reset and staff invitations use single-use, SHA-256-hashed, time-limited tokens. The raw token is only ever shown once (returned in the API response in non-production environments only, since no email provider is wired up in this prototype : see section 11).
- TOTP-based MFA is implemented (
otplib) and available to any account via/api/auth/mfa/setupand/api/auth/mfa/enable. It is not force-enabled for any role in this prototype; enforcing it for Platform Admin accounts specifically is a one-line policy check to add inonboardingRoutes.js's Admin provisioning path before go-live.
src/config/permissions.js is the single source of truth for roles and
permissions. src/db/seed.js writes this into the roles, permissions,
and role_permissions tables. src/middleware/auth.js resolves a logged-in
user's roles and permission set from the database on every request
(attachUser), and requirePermission(...) / requireRole(...) enforce it
on each route. A denied check is written to audit_logs.
| Role | Key permissions | Notably NOT permitted |
|---|---|---|
| PLATFORM_ADMIN | platform.read, platform.manage_schools, platform.manage_subscriptions, platform.manage_vouchers, platform.view_system_health, platform.view_security_logs, platform.manage_incidents | Any school financial data (fees, payments, expenses, profit) : the admin router does not join those tables at all |
| SED | school.read, finance.read, profit.read, reports.read, students.read, staff.read, academics.read, resources.read, infrastructure.read, expenses.read, fees.read, school_users.manage | students.manage, staff.manage, expenses.manage, infrastructure.manage (SED is read-only + can invite staff, by design) |
| HEAD_TEACHER | students.read/manage, academics.read/manage, staff.read, resources.read, infrastructure.read/manage, expenses.read | finance.read, profit.read (no profit visibility) |
| ACCOUNTANT | students.read, enrollment.manage, fees.read/manage, payments.manage, expenses.read/manage, staff.read/manage | infrastructure.manage, profit.read, finance.read |
This is enforced and tested : see section 9, the automated test matrix.
Every school is a tenant. src/middleware/tenant.js's
requireSchoolContext resolves req.schoolId exclusively from the
authenticated session (req.user.schoolId), never from a URL parameter,
header, or request body. Every school-scoped query in schoolRoutes.js and
dashboardRoutes.js filters by req.schoolId. A request for a resource
belonging to another school returns 404, not 403 : the resource simply does
not exist within the caller's tenant scope, which avoids confirming to a
would-be attacker that the resource exists elsewhere.
Public-facing resource identifiers (stu_..., fee_..., pay_..., etc.)
are random UUID-based strings (src/utils/ids.js), not sequential
database IDs. This is a UX/obfuscation measure only, not a substitute for
the tenant-scoping check described above.
src/services/subscriptionService.js is the single authority for a
school's subscription status: trial → active → expiring_soon → grace_period → expired, with frozen/suspended/cancelled as
Admin-controlled states. Status is computed from subscriptions.ends_at
and re-derived (never trusted from a stored value alone) by:
src/middleware/tenant.js'senforceSubscriptionForWrites, on every school-scoped request, which blocks POST/PUT/PATCH/DELETE (but not GET) once a school's status no longer allows operational writes.src/jobs/subscriptionSweep.js, which runs hourly and persists any status transition, writing an audit log entry for each one.
Grace period length is 7 days (GRACE_PERIOD_DAYS in
subscriptionService.js); "expiring soon" is also a 7-day window before
expiry. Both are easy to make configurable per-plan if needed.
Vouchers are issued by a Platform Admin (POST /api/admin/vouchers) as a
random token; only its SHA-256 hash is stored. Redeeming a voucher
(POST /api/admin/vouchers/redeem) extends a school's subscription by the
voucher's duration_days, calculated entirely server-side from the
school's current subscriptions.ends_at (extending from the later of "now"
or the current expiry, so unused time is not lost). The client only ever
supplies a voucher code and a school id : never a duration.
In this prototype, only a Platform Admin can redeem a voucher on a school's
behalf. Letting a school redeem its own voucher code directly is a
reasonable Version 2 addition (a new POST /api/school/vouchers/redeem
route restricted to school_users.manage or a new permission, with the
same server-side duration calculation).
src/services/auditService.js writes to two tables:
audit_logs: business actions (created/updated/status-changed on any resource, authorization denials, subscription transitions, voucher issuance/redemption, school freeze/unfreeze).security_events: authentication-adjacent events (failed logins, account lockouts, MFA failures).
Neither table is ever written with a password, token, or other secret value.
GET /api/admin/audit-logs and GET /api/admin/security-events expose
these to Platform Admins holding platform.view_security_logs.
Run the automated suite with:
cd app/backend
npm install
npm testThis runs src/tests/api.test.js against a real, temporary SQLite database
and a real running instance of the Express app (not mocks). It covers the
test matrix below.
| Actor | Action | Expected | Verified by |
|---|---|---|---|
| SED | Read own dashboard incl. profit | Allowed | api.test.js |
| Head Teacher | Read SED profit dashboard | Denied (403) | api.test.js |
| Accountant | Create infrastructure project | Denied (403) | api.test.js |
| Accountant | Record a payment | Allowed (201) | api.test.js |
| School user (any role) | Reach a platform admin route | Denied (403) | api.test.js |
| Platform Admin | List schools | Allowed, with no financial fields in the response | api.test.js |
| School B user | Read School A's students | Returns School B's own data only | api.test.js |
| School B user | Record a payment against a School A fee id | Denied (404) | api.test.js |
| Expired school user | Read own records | Allowed | api.test.js |
| Expired school user | Create a staff record (write) | Denied (402) | api.test.js |
| Any account | 5 consecutive failed logins, then a 6th attempt | Locked (423), even with the correct password | api.test.js |
| Trial signup | Submit role: "PLATFORM_ADMIN" in the request body |
Ignored : account is created as SED only | api.test.js |
To extend this matrix (e.g. Admin unfreezing a school, voucher redemption,
MFA enrollment), add cases to src/tests/api.test.js following the existing
pattern : the test app boots against an isolated temp SQLite file per run,
so tests do not interfere with your development database.
Running npm run seed (see section 12) creates:
Platform Admin (from .env's BOOTSTRAP_ADMIN_EMAIL/PASSWORD :
only created if no Admin account exists yet):
- Email: value of
BOOTSTRAP_ADMIN_EMAIL - Password: value of
BOOTSTRAP_ADMIN_PASSWORD
Two demo schools (non-production environments only : the seed script
skips demo data when NODE_ENV=production), password DemoPass!2026 for
every demo account:
| School | SED | Head Teacher | Accountant | Notes |
|---|---|---|---|---|
| Uwezo Secondary School (Demo) | sed@uwezo.demo | headteacher@uwezo.demo | accountant@uwezo.demo | Active subscription, ~300 days remaining |
| Amani Primary School (Demo) | sed@amani.demo | headteacher@amani.demo | accountant@amani.demo | Active but expiring in ~12 days : use this one to see the "expiring soon" banner |
These are demo credentials only. Never reuse this password pattern in a
real deployment, and never seed demo data in production
(NODE_ENV=production already prevents it).
- No real email provider is wired up. Password reset tokens and
invitation tokens are returned directly in the API response when
NODE_ENV !== 'production', purely so the prototype is testable end-to-end without external services. Before production, wireauthRoutes.jsandonboardingRoutes.jsto an actual transactional email provider and remove thedevOnlyResetToken/devOnlyInviteTokenfields. - SQLite, not PostgreSQL, is the default in this prototype. See section 13 for the migration path. The schema and query style were written to make that migration mechanical.
- File upload validation for the school logo is minimal : MIME-type and
size checks are not yet wired into a real upload endpoint (the logo input
in the UI currently only does a client-side preview). Before production,
add a
POST /api/school/logoroute that validates MIME type, actual file signature, size, and stores the file outside any web-executable path. - MFA is available but not enforced for any role yet (see section 3).
- No automated backup/restore tooling is included. Section 14 documents the intended strategy; implementing scheduled backups is an operations task tied to your actual hosting environment.
- Notification delivery is not implemented : the
notificationstable exists in the schema but nothing currently writes to it or renders it in the UI. Subscription-expiry banners in the dashboards are computed live from subscription status rather than being pre-generated notifications. - Reports are the dashboard views themselves : there isn't yet a separate, filterable "Reports" export/PDF area distinct from the dashboards. The dashboard API endpoints already support the underlying queries; a dedicated reports UI can be layered on top.
cd app/backend
cp .env.example .env # edit values, especially SESSION_SECRET
npm install
npm run migrate # applies src/db/migrations/*.sql
npm run seed # roles/permissions + bootstrap admin + demo data
npm start # serves the API and the static frontend on PORT (default 4000)Then open http://localhost:4000/ in a browser. The backend also serves
the frontend directly (see app.js), so there is nothing separate to run
for the UI in this prototype.
The schema in src/db/migrations/001_init.sql was written to translate
directly:
INTEGER PRIMARY KEY AUTOINCREMENT→SERIAL PRIMARY KEY/BIGSERIALTEXTtimestamp columns (ISO-8601 strings) →TIMESTAMPTZ, withDEFAULT now()instead ofstrftime(...)CHECK (status IN (...))constraints translate as-isPRAGMA foreign_keys = ONhas no PostgreSQL equivalent (FKs are always enforced)
The application code accesses the database only through
src/db/connection.js. To swap engines: introduce a pg client behind the
same db.prepare(...).get()/.all()/.run() shape (or refactor the
repositories to use pg's native query style directly : a clean refactor
opportunity given the current code is small enough to touch every call
site), point DB_CLIENT=postgres at your instance using the PG_*
variables in .env, and re-run the (translated) migration. No route or
service file should need business-logic changes : this was a deliberate
constraint while building the SQL layer.
For PostgreSQL in production: nightly pg_dump to encrypted object storage,
7 daily + 4 weekly + 3 monthly retention, and a quarterly restore drill into
a scratch environment to confirm backups are actually restorable. This is
documented here as the intended approach; it is infrastructure work tied to
your actual hosting provider and is not implemented in this codebase.
The schema and permission system were designed so Student, Teacher, and Parent actors can be added without a rebuild:
staff.user_idalready links a staff record to an optional login account : a Teacher role can reuse this link rather than needing a new table.studentshas nouser_idyet; adding one plus aSTUDENTrole and a narrowPERMISSIONSset (e.g.own_academic_records.read) is additive.- A
PARENTrole would need a newguardiansjoin table (parent user ↔ one or more students) and permissions scoped to "read records for my linked students," which the existing tenant-scoping pattern inschoolRoutes.jsalready supports (add aAND student.id IN (linked ids)clause rather than a new authorization model). - None of this is implemented in Version 1 by design : see the product README for what Version 1 actually covers.
- Passwords hashed (bcrypt in this prototype; Argon2id recommended for production)
- Server-side sessions, httpOnly + SameSite cookies
- Session regeneration on login
- Account lockout + rate limiting on auth endpoints
- RBAC enforced server-side, tested
- Tenant isolation enforced server-side, tested
- Subscription enforcement server-side, tested
- Audit logging for sensitive actions
- Parameterized queries throughout (no string-concatenated SQL)
- Errors never leak stack traces to clients
- Non-enumerating auth error messages
- No false claims of end-to-end encryption anywhere in the product copy
- TLS/HTTPS : must be terminated by your hosting/reverse proxy; not
something an application-layer prototype can provide on its own.
Set
COOKIE_SECURE=trueonce it's in place. - Production email delivery (see section 11)
- File upload hardening for the school logo (see section 11)
- MFA enforcement policy for Platform Admin accounts (see section 3)
- Provision a PostgreSQL instance; migrate the schema (section 13).
- Set all
.envvalues for production : a long randomSESSION_SECRET,COOKIE_SECURE=true, realPG_*credentials, and removeBOOTSTRAP_ADMIN_*after the first Admin account is created. - Put the app behind a reverse proxy/load balancer that terminates TLS.
- Run
NODE_ENV=production npm run migrate(seed script will skip demo data automatically underNODE_ENV=production). - Create exactly one bootstrap Platform Admin, log in, enable MFA on that
account, then remove the bootstrap variables from
.env. - Wire a real transactional email provider for password resets and staff invitations (see section 11).
- Configure your process manager (e.g. systemd, PM2) to run
npm startand restart on failure; point your reverse proxy atPORT.