Note (2026-08-29): this document describes the BACKEND and the WEB app, both unchanged. Accounts were removed from the Android build on 2026-08-27, so the packaged app no longer exercises any of the authentication, token, rate-limiting or roster surfaces below — it makes three unauthenticated content GETs and nothing else. None of the controls here are obsolete: they still protect learn.bodhya.net/play and the facilitator Desk, which keep accounts. Treat this file as covering the server and the web client, not the Play Store app.
Bodhya Learn stores data about minors. Treat it accordingly. This is the operational checklist that complements the in-code hardening (PIN hashing, login tokens, rate limits, input validation, cached read APIs).
- Strong Administrator password. Set a long, unique password during site
creation. Never ship the dev default (
admin123). Rotate any bootstrap value. - Enable two-factor auth for Desk. Frappe → System Settings → enable Two Factor Authentication (at least for System Manager accounts).
- Minimise System Manager / facilitator accounts. The Desk (
/app) is the admin surface; only staff who need it get a Desk login (+ 2FA). Campus students are not Frappe Users; online students are login-only Website Users (no email, no Desk) — see the enrolment model below. - HTTPS only (Frappe Cloud does this automatically). Required for the PWA and for protecting login tokens in transit.
- Verify the client IP the app derives — see Client IP / trusted proxies below. Zero configuration is correct for the two common topologies, but a managed host or CDN in front needs its ranges added, and nothing else in the app can tell you it is wrong.
- (Optional) IP-allowlist / VPN-gate
/appif the centre uses fixed locations.
Every per-IP ceiling — and the per-source half of the PIN lockout — is keyed on the address
the app derives for the caller. X-Forwarded-For is client-supplied text, so it is believed
only when the request's socket peer is itself a trusted proxy; then the chain is walked
right to left (nginx appends what it saw), trusted addresses are skipped, and the first
untrusted entry is the client. Otherwise the header is ignored and the socket peer is used.
- Default trusted set (no config needed): loopback + RFC1918 + link-local + IPv6
unique-local — exactly where a reverse proxy of ours can sit. A public address is
never trusted by default. This is already correct for:
- a directly exposed app (no proxy): the header is ignored, so it cannot be spoofed;
- one nginx on localhost (standard bench / Frappe Cloud site): the rightmost non-private entry is the real client.
hikmat_trusted_proxies(site config) overrides the set — a list of IPs/CIDRs, e.g.["203.0.113.0/24", "2001:db8::/32"]. Add the egress ranges of anything public in front of the site (a CDN, an external load balancer, a managed platform's edge). An empty list means trust nobody, i.e. ignoreX-Forwarded-Forentirely — that is what the dev bench sets, because gunicorn there listens on localhost and localhost would otherwise be a trusted peer.hikmat_trusted_proxy_hopsis obsolete (it was a hop count, wrong in both directions and invisible either way). If a site still carries it, the app logs an error saying so. Remove it.- What an operator must actually verify, once, per deployment: open
/api/method/hikmat.api.whoami_ipfrom a device whose public IP you know, logged in as a System Manager.client_ipmust equal that device's public IP.- If it comes back as a proxy/edge address (every client would then share one rate
limit bucket — with the fail-closed signup/capture ceilings that can lock a site out),
add that proxy's range to
hikmat_trusted_proxies. - If it echoes a value you can set yourself with a spoofed
X-Forwarded-For, the peer is being trusted when it should not be: set the list to[].
- If it comes back as a proxy/edge address (every client would then share one rate
limit bucket — with the fail-closed signup/capture ceilings that can lock a site out),
add that proxy's range to
- Self-diagnosis: when the peer is trusted but the whole chain is trusted or unreadable
(so no client address exists), the app logs an ERROR naming
hikmat_trusted_proxiesinlogs/hikmat.log. Grep that file after go-live. A LAN deployment where the learners' devices are themselves on private addresses is the normal case for this warning — fix it by trusting only the proxy (["127.0.0.1"]), so the LAN clients stay distinct. - Ceilings are deliberately generous because a whole classroom NATs to one IP: measured
headroom is ≥166 girls/hour on every ceiling except signup (60/hour), which is the
tightest. A 30-girl class enrolling at once uses half of it; a 60+ girl mass sign-up
behind one IP would be refused (
rate_limited, retried by the client) — stagger it, or create campus learners in Desk, which does not go through signup.
- A PIN is required for every profile (4–8 digits) and verification is
fail-closed — a PIN-less profile cannot be logged in (closes the old shared-laptop
hole where a PIN-less profile opened with zero auth). PINs are hashed
(
pbkdf2:sha256); legacy plaintext upgrades on next login. - Short numeric PINs are fine because login has a per-account lockout, and PINs only
separate kids on shared laptops. The lockout is keyed on the account — the Student
docname for
login_student, the typed name forlogin_by_name— and never on the client IP: it used to include the IP, which made it worthless (eight wrong PINs from eight spoofedX-Forwarded-Forvalues never tripped it, so a 4-digit PIN was brute-forceable). Two budgets, both cleared by a successful login:- 8 wrong PINs → 5-minute cooldown (unchanged), which lifts by itself;
- 50 wrong PINs in 24h → the profile parks. This is what actually defeats brute force
(~200 days to cover half a 4-digit space, versus ~2 days on the 5-minute counter alone).
A per-source ceiling of 600 failed logins/hour limits spraying many accounts from one
place; it counts failures only, so normal logins never touch it.
Note
login_by_namecan only key on the name it was given, and names are not unique — two girls called Asha share that budget (the name is hashed into the cache key, so no child's name sits in Redis). If a duplicate name ever causes trouble, clear it withclear_login_lockout(name="Asha")and give one of them a distinct display name.
- The trade-off, on purpose: a per-account lockout means someone can deliberately lock a
child out of her own profile. It is priced down as far as it goes — generous budgets (a
girl mistyping two or three times is nowhere near them), a bounded 24h window, and a
facilitator release valve:
clear_login_lockout(student=…)(orname=…, orip=…), System-Manager only, which also reports how many failures it found so you can tell a real lockout from a girl who keeps mistyping. A provisioned campus laptop is unaffected either way — it verifies her PIN on-device against the cached roster hash and never calls these endpoints, so the lockout only binds the typed-name/online path. - Each student gets a per-student token at login/signup, required by
submit_attempt/get_progress. Tokens expire after 90 days (sliding window — an active login refreshes it) and rotate when missing/expired. A facilitator can force re-login everywhere withrevoke_student_token(student)(Desk-only), e.g. for a lost or handed-down laptop. - Unticking
activeon a Student now cuts off her device immediately, on every endpoint — it is checked in the shared auth path, not per endpoint. (It used to gate only new logins and one write path, so a deactivated girl's cached token kept working for up to 90 days unless someone also calledrevoke_student_token.) Deactivate to offboard;revoke_student_tokenis for rotating a token on an account that stays live. Withdrawn consent: deactivate for an immediate stop, thendelete_studentto erase what is already stored — deactivation alone deletes nothing. - Login is by name + PIN (
login_by_name, indexed lookup) — the roster is never listed publicly and errors are generic (no "does this name exist?" enumeration). - Self-signups still require a guardian/teacher consent acknowledgement in the UI.
- A Cohort is a start-date intake batch (e.g. "Aug 2026",
start_date), not a physical centre; a batch can hold both campus and online learners. - Each Student has
mode(Campus / Online), an optionalcampus(Link → Campus, e.g. Noor Girls High School, Meghwal Mathia), anduser(Link → Frappe User, online learners only). - Campus (offline) learners log in on shared laptops from a cached roster with an on-device PIN check. Online learners (Phase 2) self-register with a per-cohort invite code and log in as a login-only Website User (username + PIN, no email).
- The AI voice tutor (Roshni) is deferred for now. A learner's "I'm stuck / help" tap logs a Lesson Doubt for the teacher in Desk (the Confusion Heatmap report). Actively notifying the facilitator in Desk on each new doubt is the next small step — there is no student-facing bot.
- Lesson Attempt rows denormalise
student_name/cohortfor reporting and grow over time. To erase a child's data, usehikmat.api.delete_student(student)(facilitator/System-Manager only) — it cascades over her attempts, tests, doubts, events, attendance, evaluations, AI chats, and the Frappe User of an online learner. It also scrubs the bookkeeping rows Frappe leaves behind naming her (delete-feed Comments, Versions, Notification Logs, DocShares), which its own queued cleanup does not reach on a site with no worker. A half-finished erasure is resumed rather than refused, so a run interrupted part-way can simply be re-run. - The app no longer records audio. The Bhojpuri AI ("Boli") corpus feature — recording,
transcription, verification, the speaker registry and the XP ledger — was removed
outright, and the
v12_remove_bolimigration erases everything it had collected: the rows, the doctypes and tables behind them, and the private audio bytes on disk. Nothing in the app asks for microphone access any more. - Erasing one girl never costs another one anything. Erasure is scoped to rows keyed on her id; a peer's rows are never touched.
- Decide and document a retention window (e.g. purge inactive students' attempts
after N years) before a full rollout. A scheduled job can be added to
scheduler_eventsinhooks.py. - Keep a short privacy notice for parents/teachers describing what is stored (first name/nickname, avatar, progress) and how to request deletion.
A fresh install (bench new-site + install-app hikmat, or a Frappe Cloud deploy)
runs after_install which seeds content, belts, the campus, the two cohorts
(Online / NGHS Sept-2026), a random invite code, and the login System
Settings (username login on, password policy off). Patches do NOT run on fresh
installs — everything they seed is mirrored in seed_operational_defaults().
- Admin password: set a strong, unique Administrator password; enable 2FA for every System Manager (Desk → Two Factor Authentication settings).
- Invite code: read the generated code from the Online cohort (Desk → Cohorts) and share it only through the facilitator channel. Rotate it there any time it leaks.
- Facilitator accounts: one named Desk user per facilitator (System Manager for now — a scoped role is future hardening); no shared logins.
- Learner data: if a copied/dev database is ever promoted, run
bench --site <site> execute hikmat.setup_data.wipe_demo_dataso real analytics start from zero (content, belts, cohorts and settings survive the wipe). - Campus laptop provisioning (offline path): a facilitator opens the game → ⚙️ Set-up → logs in → picks the campus → the roster caches on-device → log out. Verify a girl can then log in by name + PIN with wifi OFF.
- Smoke test (5 min): guest plays a lesson → 2-lesson wall appears → online signup with the invite code works → Desk shows the attempt, and the Trouble Spots / Drill-down reports fill in.
- Backups: on Frappe Cloud they're automatic; self-hosted, schedule
bench --site <site> backupoff-machine.
Found a vulnerability? Email vishal@fossunited.org — please don't open a public issue for security problems.