A self-hostable spaced-repetition language learning engine. Express + vanilla ES modules, an SM-2 scheduler, deterministic answer grading, and six optional integrations that each degrade to a working fallback when unconfigured. Vocabulary is data, not code — a course is a JSON deck plus a registry entry.
git clone https://github.com/antmlap/ArmoLingo.git && cd ArmoLingo
npm install && npm starthttp://localhost:3000. No build, no database, no API keys, no config file. Three demo decks ship with the repo — Eastern Armenian, Western Armenian, and Spanish, the last one to demonstrate the engine is language-agnostic.
It began as a production Armenian web app with paying subscribers. This is that app rebuilt as a general engine; the refactor section lists the four production bugs the restructuring surfaced.
| Server | 4,479 lines, 36 modules, largest file 457 |
| Client | 2,619 lines, 13 ES modules, 23 KB gzipped, no bundler |
| Tests | 185, running in ~0.4 s, zero test dependencies |
| HTTP surface | 33 endpoints |
| Dependencies | 10 direct, 110 installed, 0 dev |
| Cold start | ~90 ms to listening |
| Latency (local, p50/p99) | grade 0.24/1.24 ms · 12-question quiz 0.34/1.02 ms |
| Persistence | Postgres, or a zero-dependency JSON driver behind one contract |
- Engineering highlights
- Architecture
- What the rebuild found
- Testing strategy
- What it does
- Bring your own vocabulary
- Configuration
- Security posture
- Deployment
- Project layout
- Documentation
A pure function (card, rating, now) → card. No ambient clock, no I/O, no
mutation — which is why the tests simulate a year of reviews in milliseconds and
assert on resulting intervals instead of mocking time.
new card ──1 min──> ──10 min──> graduate at 1 day
│
review: interval × ease ease ∈ [1.3, 3.0], starts 2.5
Δease: again −0.20 · hard −0.15 · good 0 · easy +0.15
hard advances at ×1.2 only; easy skips the learning phase to 4 days
capped at 365 days
Three decisions that separate a working scheduler from a naïve one:
- A lapse returns the card to the learning phase, not to a shorter interval. A card you could not recall is a new card.
- A lapsed card retains 50% of its old interval. A mature card that slips once should not restart from one day — losing months of history to a single bad morning is how learners abandon an SRS.
- Day-scale intervals are fuzzed ±5%. Without it, cards learned in the same session stay permanently clumped and every review day is feast or famine.
Cards carry a version and a migration path, so scheduling can change without a
data migration — the server persists this state as an opaque blob (see §4).
src/services/grading.js · tests
Levenshtein over a two-row buffer, iterating the shorter string: O(mn) time,
O(min(m,n)) space. Normalisation folds NFD combining marks, case, and
punctuation, but deliberately preserves @, which romanisation schemes use as a
real letter (the Armenian schwa in hats@).
tolerance(len) = 0 len ≤ 4
min(3, ⌊len/7⌋ + 1) otherwise
No slack under five characters, because at that length one edit is usually a
different word rather than a typo. grasias → gracias is accepted at 0.8
score; a → agua is rejected. The predecessor accepted an answer when either
string contained the other, which passed a for agua and failed correct
answers over a stray accent.
Grading is local and deterministic rather than model-backed: a learner answering a card should not wait on a network round trip, and the behaviour is testable at its boundaries.
src/services/exercises.js · tests
Wrong answers are ranked by confusability — shared initial character, length
within two — then sampled from a shortlist of max(3n, 8) using a seedable
Mulberry32 PRNG. Plausible enough to require knowing the word, varied enough that
the same card is not memorised by its options. Seeding makes quizzes reproducible
and the tests exact.
Fill-in-the-blank removes the term from its own example sentence, with word
boundaries applied only on sides where the term begins or ends with a word
character — an unconditional \b…\b fails on (approx) while still needing to
reject jur inside Jurnal.
A documented interface with a Postgres implementation and a zero-dependency JSON
implementation. Normalised across both: camelCase keys, ISO 8601 timestamps,
null never undefined. Nothing outside stores/ knows which driver is
active.
The same 30-test suite runs against both, Postgres
included in CI via a service container. That is the mechanism, not the aspiration:
the predecessor had ~40 hand-written if (hasDb) branches inline in route
handlers, and they had silently diverged (see § What the rebuild found).
The JSON driver is a development convenience, but not a careless one — writes go
to a temp file then rename(2) so an interrupted write cannot truncate state,
writes are serialised through a promise chain so overlapping snapshot saves cannot
clobber each other, and the users file is chmod 0600 because it holds bcrypt
hashes and reset tokens. It writes to a gitignored var/, never beside
checked-in content.
Progress is stored as an opaque JSON blob keyed by (user, course). The
server does not interpret it, which is what allows §1 to evolve freely. The cost —
the server cannot report on learning progress — is stated rather than discovered.
src/services/entitlements.js · tests
One 40-line function maps (user, now) → { canStudy, plan, batchesRemaining, reason }
across free · active · trialing · cancelled · past_due, plus billing-disabled and
guest. Two rules that are easy to get wrong and are each pinned by a test:
- A cancelled subscription retains access until
subscriptionEndDate. The period was paid for; revoking at cancellation is charging for time not served. - A past_due subscription is blocked including the free allowance. Leaving the free tier open to a failed payment hands a lapsed subscriber a usable product and no reason to fix their card.
script-src carries no 'unsafe-inline'. A 16-byte nonce is generated per
response and stamped into the single inline bootstrap block. The host allowlist is
assembled from the features actually enabled — no Stripe origins without billing
keys, no Tag Manager without an analytics ID.
This is why the SPA shell is rendered rather than served statically: the render step also injects runtime config, replacing an arrangement where the analytics measurement ID and feature flags were hardcoded in markup, so every fork reported telemetry to the original author's property.
Sliding window over a Redis sorted set, with an in-process fallback. Every Redis
call is wrapped in a 250 ms timeout and falls through to the local limiter on
breach, and the client runs with enableOfflineQueue: false so commands issued
while disconnected fail fast instead of queueing.
Both matter: without them a slow or half-open Redis turns a cache blip into 30-second request timeouts. The fallback is per-instance and therefore weaker — documented as an accepted trade for a limiter whose job is blunting brute force, not metering billing.
Mounted with a raw-body parser before express.json(), because the HMAC
covers the exact bytes Stripe sent. Subscription state is mirrored onto the user
row so the hot path — "may this learner open the next lesson" — is a local read.
The endpoint returns 200 once the signature verifies, even if a handler threw.
A non-2xx makes Stripe redeliver, and a handler that failed on a payload will fail
identically on retry while the event queue backs up behind it. Failures are logged
and reconciled by an idempotent /api/billing/sync. Checkout is additionally
verified on redirect, because the webhook and the browser race and the learner
expects to be unlocked immediately.
flowchart TB
subgraph browser["Browser · native ES modules, no bundler, 23 KB gzipped"]
MAIN["main.js<br/>shell & routing"]
VIEWS["views/<br/>lesson · quiz · review · tutor"]
SRS["srs.js<br/>SM-2 scheduler (pure)"]
PROG["progress.js<br/>localStorage → debounced sync"]
APIC["api.js<br/>sole fetch call site"]
end
subgraph server["Express"]
MW["middleware/<br/>TLS · CSP nonce · session · errors"]
ROUTES["routes/ · 33 endpoints<br/>validate → rate limit → serialise"]
SVC["services/<br/>grading · exercises · entitlements<br/>courses · tutor · tts · billing"]
STORE["stores/<br/>one contract"]
end
REG["config/courses.json<br/>+ data/decks/*.json"]
subgraph opt["Optional · each key-gated with a fallback"]
PG[("Postgres")]
RD[("Redis")]
EXT["OpenAI · Google TTS<br/>SendGrid/Resend · Stripe"]
end
MAIN --> VIEWS --> APIC
VIEWS --> SRS --> PROG --> APIC
APIC -->|/api| MW --> ROUTES --> SVC --> STORE
SVC --> REG
STORE -->|DATABASE_URL| PG
SVC -.-> RD & EXT
Route handlers are thin by rule: validate, rate limit, call one service, serialise. Every decision that could be wrong lives in a service, pure where possible, with tests. That is what makes grading boundaries and a year of simulated scheduling assertable without an HTTP server in the loop.
Request lifecycle. trust proxy → HTTPS enforcement (redirect for safe
methods, 403 for unsafe — a 301 on a POST silently drops the body) → CSP nonce
→ security headers → Stripe raw body → JSON body → session resolution → route →
error handler. Unmatched /api/* returns a JSON 404 rather than falling through
to the SPA; everything else renders the shell so client routing survives a hard
refresh.
Full rationale in docs/architecture.md, including a candid list of what is deliberately unbuilt.
Restructuring a working 2,500-line server.js into layered modules surfaced four
bugs that were live in production. Each is now covered by a regression test.
| Bug | Impact | Root cause |
|---|---|---|
PUT /api/auth/profile never wrote to Postgres |
Changing dialect silently did nothing on the live site | Handler mutated the in-memory array; the JSON branch was written, the DB branch was not |
| Cancelling revoked access immediately | Subscribers lost time they had already paid for | Three inlined copies of the access check; one omitted the end-date condition |
| Every MCQ offered the same three wrong answers | Quizzes passable by recognising the distractors | pool.slice(0, 3) instead of sampling |
| Substring matching in the grader | a accepted for agua |
ua.includes(ref) || ref.includes(ua) behind a length floor |
The first two are the argument for the storage contract and the pure entitlement function respectively: both bugs were only possible because the same decision existed in more than one place.
A fifth issue was operational rather than behavioural — the previous logger
appended to data/server.log inside the repository, producing a 605 KB file
with a user email address on nearly every line. Logs are now structured JSON on
stdout with addresses masked and credential-shaped fields redacted.
npm test # 185 tests, ~0.4 s
TEST_DATABASE_URL=postgres://localhost/armolingo_test npm test # + PostgresNode's built-in runner. No test framework, no assertion library, no mocking library, no dev dependencies at all.
| Suite | Tests | Approach |
|---|---|---|
http |
39 | Real app on an ephemeral port, hand-rolled cookie jar, fetch |
srs |
30 | Injected clock and PRNG; simulates months of review history |
store-contract |
30 | Per driver — one suite parameterised over both |
exercises |
26 | Determinism under seed, distractor invariants over 30 seeds |
grading |
23 | Tolerance boundaries, Unicode folding, adversarial inputs |
deck |
22 | Validation, legacy migration; asserts the shipped decks are valid |
entitlements |
15 | Every subscription state × billing on/off |
Three deliberate choices:
Hermetic by construction. test/setup.js deletes every integration key from
the environment and redirects storage to a temp directory. An OPENAI_API_KEY in
a developer's shell must not flip a feature flag and make a suite pass locally
that fails in CI.
The browser scheduler is tested as shipped, not reimplemented. public/js/
carries a {"type":"module"} marker so Node can import() the same ES module the
browser loads.
Pure functions get boundary tests; impure code gets integration tests. There are no mocks anywhere in the suite — the seam is the storage contract and the HTTP boundary, not a stubbed module graph.
CI runs the matrix on Node 22 and 24 against a live Postgres service container,
validates every bundled deck, audits dependencies at --audit-level=high, and
boots the server to assert /api/health.
Lessons. Decks are the curriculum, served as positional batches, so batch N is the same words for everyone and a learner's place is an integer. Cards show the target language first; revealing the translation is a deliberate action, because a card that hands over the answer trains recognition rather than recall.
Quizzes. Four exercise types — multiple choice in both directions, free typing, and fill-in-the-blank generated from the card's own example sentence. Grading feeds the scheduler, so quizzing is the review mechanism rather than a feature beside it.
AI tutor (optional). Persona, target language, and stock phrases come from the course definition; the prompt is grounded in the exact cards being studied. Without an API key the panel falls back to a ranked deck search, so it is never a dead end.
Pronunciation (optional). Server-proxied Google TTS with an LRU cache keyed on
sha256(lang|voice|text), so a study session costs roughly one synthesis per
distinct phrase. Native script is preferred over romanization as synthesizer input,
because a synthesizer reading a romanization pronounces it as English. Proxied
rather than called client-side so the API key is not shipped to every visitor.
Accounts (optional). Progress works signed out in localStorage; an account
syncs it. One-click export of everything held, and irreversible deletion that also
cancels the subscription and deletes the Stripe customer.
Subscriptions (optional). Free-batch allowance and Stripe Checkout. With no keys configured the paywall does not exist — a self-hoster teaching their own deck should never meet one in their own app.
Two required fields. Schema: data/deck.schema.json.
[
{ "term": "hola", "translation": "hello",
"example": "Hola, ¿cómo estás?", "exampleTranslation": "Hello, how are you?" },
{ "term": "barev", "translation": "hello", "script": "բարև",
"example": "Barev, inchpes es?", "exampleTranslation": "Hello, how are you?",
"exampleScript": "Բարև, ինչպես ես։" }
]term is what the learner types, so for non-Latin scripts this is the
romanization — it is what a QWERTY keyboard can produce. script holds the real
orthography, is displayed alongside, and is what gets sent to speech synthesis.
Deck order is the curriculum; put the most useful words first.
cp my-words.json data/decks/japanese.json # 1. add the deck
$EDITOR config/courses.json # 2. register id, deck path, tts voice, tutor persona
npm run deck:validate && npm start # 3. verify and runThe validator reports missing fields, duplicate terms, orphaned examples, and unknown keys with the index of each. It runs in CI, and a test asserts every deck in the registry is valid.
npm run deck:from-csv -- --input words.csv --output data/decks/japanese.jsonDependency-free RFC 4180 parser handling quoted fields, embedded delimiters and
newlines, and doubled quotes. Accepts a range of column aliases, de-duplicates on
term, and sorts by a rank column when present.
OPENAI_API_KEY=sk-... npm run deck:generate -- \
--language Japanese --words words.txt --output draft.json --scriptDrafts example sentences, mnemonics, and native-script spellings under a JSON schema constraint, batching requests and self-flagging low-confidence entries. These are drafts — a model will confidently produce a wrong particle. Full documentation in docs/decks.md.
Every process.env read in the codebase happens in
src/config.js and is validated once at boot.
assertProductionSafety() refuses to start in production without a real
JWT_SECRET, or with Stripe keys and no webhook secret.
| Unset variable | Degrades to |
|---|---|
DATABASE_URL |
JSON file driver under var/ |
REDIS_URL |
In-process sliding-window limiter |
OPENAI_API_KEY |
Tutor falls back to ranked deck search |
GOOGLE_TTS_API_KEY |
Audio controls hidden |
SENDGRID_API_KEY / RESEND_API_KEY |
Reset links written to the log |
STRIPE_SECRET_KEY |
No paywall; everyone unlimited |
ANALYTICS_MEASUREMENT_ID |
No analytics script, no consent banner |
JWT_SECRET |
Dev-only default — fatal in production |
GET /api/health reports which took effect, with no secrets in the response.
Full detail and a candid limitations list in SECURITY.md.
| Concern | Mechanism |
|---|---|
| Credentials | bcrypt; never logged, never serialised by any endpoint |
| Account enumeration | Identical message and identical timing — a dummy hash is compared when no account exists, so login cost does not reveal existence. Password reset always answers identically |
| Brute force | Per-IP and per-address sliding windows plus a lockout, deliberately in-process so it cannot be weaponised to lock a known user out permanently |
| Reset tokens | 32 random bytes, stored only as SHA-256, one hour, single use |
| XSS | Client DOM built through a helper that assigns textContent; deck content and model output cannot inject markup, with no sanitiser in the trust path |
| CSP | Per-response nonce; script-src has no 'unsafe-inline' |
| Webhooks | HMAC-verified against the raw body; fatal misconfiguration if unverifiable |
| Error leakage | Only deliberate HttpErrors carry a message; everything else is a flat 500 |
| PII in logs | Emails masked, credential-shaped keys redacted, stdout only |
Secret scanning and push protection are enabled on this repository, and the full history has been scanned with gitleaks.
Stateless with DATABASE_URL set. Reads PORT, logs JSON to stdout, drains on
SIGTERM with a 10-second forced exit so a stuck socket cannot block a deploy.
npm ci --omit=dev
NODE_ENV=production JWT_SECRET="$(openssl rand -base64 48)" \
DATABASE_URL=postgres://… npm startSchema applies idempotently at boot; npm run db:migrate [-- --print] if you
prefer it as a discrete release step. Procfile and app.json included.
See docs/deployment.md for proxy configuration, Stripe
webhook setup, scaling notes, and a troubleshooting table.
src/
index.js Boot: validate config → load decks → migrate → listen
config.js The only process.env reads, validated once
app.js Middleware composition (order is load-bearing)
routes/ 33 endpoints. HTTP concerns only
services/ Domain logic. Pure where possible
stores/ One contract, two drivers
middleware/ Sessions, CSP + headers, error shaping
db/ Postgres pool and schema, Redis client
lib/ Logger, rate limiter, HTTP errors, deck validation
public/
index.html Shell, server-rendered to inject nonce + runtime config
css/app.css Design tokens, dark mode via prefers-color-scheme
js/ ES modules; srs.js, store.js, progress.js, api.js, views/
config/courses.json The course registry
data/decks/ Vocabulary; data/deck.schema.json describes the format
scripts/ Deck validation, CSV import, AI drafting, migration
test/ 185 tests, no dependencies
docs/ Architecture · API reference · Decks · Deployment
| docs/architecture.md | How it fits together, why the debatable calls went that way, what is deliberately unbuilt |
| docs/api.md | All 33 endpoints with request/response shapes, auth, and rate limits |
| docs/decks.md | Deck format, tooling, legacy field migration |
| docs/deployment.md | Production checklist, proxies, webhooks, troubleshooting |
| CONTRIBUTING.md | Setup, house style, where code goes |
| SECURITY.md | Reporting, self-hosting guidance, known limitations |
MIT. The bundled decks are small demonstration samples; bring your own vocabulary for anything real.