Skip to content
antmlapPublic

About

Self-hostable spaced-repetition language learning app. Bring your own vocabulary deck.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ArmoLingo

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.

CI License: MIT Node Tests Dev dependencies Build step

git clone https://github.com/antmlap/ArmoLingo.git && cd ArmoLingo
npm install && npm start

http://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.

At a glance

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

Contents


Engineering highlights

1. Spaced repetition — SM-2 with a learning phase

public/js/srs.js · tests

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).

2. Answer grading — edit distance with proportional tolerance

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.

3. Distractor selection

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.

4. Storage — one contract, two drivers, one test suite

src/stores/ · contract tests

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.

5. Entitlements as a pure state function

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.

6. CSP with a per-response nonce

src/middleware/security.js

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.

7. Rate limiting that degrades under partial failure

src/lib/rate-limit.js

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.

8. Payment webhooks — verification and acknowledgement semantics

src/services/billing.js

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.


Architecture

flowchart TB
    subgraph browser["Browser · native ES modules, no bundler, 23 KB gzipped"]
        MAIN["main.js<br/>shell &amp; 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
Loading

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.


What the rebuild found

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.


Testing strategy

npm test                                                    # 185 tests, ~0.4 s
TEST_DATABASE_URL=postgres://localhost/armolingo_test npm test   # + Postgres

Node'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.


What it does

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.


Bring your own vocabulary

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 run

The 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.json

Dependency-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 --script

Drafts 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.


Configuration

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.


Security posture

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.


Deployment

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 start

Schema 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.


Project layout

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

Documentation

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

License

MIT. The bundled decks are small demonstration samples; bring your own vocabulary for anything real.

About

Self-hostable spaced-repetition language learning app. Bring your own vocabulary deck.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages