Architecture

A single Rust binary (axum + SQLite + askama; templates, static files and docs compiled in, courses and themes read from data/ at startup) sized for a Raspberry Pi Zero 2 W: ~3.5 MB, 15–25 MB RSS, two async workers, one SQLite connection in WAL mode.

Where things live

One line per area: what it owns, then its files. The file to open first comes first. Rust paths are under src/ (routes/sprint.rs is src/routes/sprint.rs). A page’s handler is in routes/<area>.rs, its markup in templates/, its script in static/. The sections below this one have the detail; stop here once you know which file to open. cargo test architecture_map fails when a file in src/ or a folder under static/, templates/ or data/ is missing from this list.

Server and plumbing

  • Start-up: main.rs (env config, commands, require_providers), startup.rs (start-up errors), doctor.rs (skrongle doctor), check_worker.rs (skrongle content-check), server.rs (listener limits).
  • HTTP: routes/mod.rs (Router, embedded static files and docs, CourseView), error.rs (AppError to responses), security.rs (headers, CSRF, rate limits).
  • Database: db.rs (connection, Db::call), db/migrations.rs (MIGRATIONS); schema in “SQLite schema” below; background jobs (the hourly housekeeping) in jobs.rs.
  • App text: i18n.rs reads i18n/<code>.yaml (docs/i18n.md). Randomness: random.rs (OS bytes), quiz.rs has the small PRNG.

Courses and content

  • Course model and validation: content.rs (Manifest, Lesson, ContentStore), content/paradigm.rs (paradigm slots and sets, who); contract in docs/course-format.md. Placement test (placement.json, #1106): placement.rs (format, drawing, marking), routes in routes/courses/settings.rs, templates/placement_test.html, static/placement.js.
  • Course registry: courses.rs (built-in, user-made, themed copies, projects); pages in routes/courses/ (routes/courses/mod.rs routes and shared views, routes/courses/picker.rs the list, routes/courses/settings.rs the settings dialog and opening, routes/courses/stat.rs the body of a card’s stat modal (fetched by static/course-stat.js), routes/courses/create.rs new, copy and translate, routes/courses/editor.rs the editor page, routes/courses/files.rs files, upload and export, routes/courses/lessons.rs the lesson table, routes/courses/generate.rs AI generation, routes/courses/revise.rs Add content, Review AI changes, Undo), static/json-problems.js (JSON problems worded as the server words them), static/lesson-editor.js, static/lesson-preview.js, static/schema-form.js, static/lesson-duplicate.js (a lesson row’s ⧉), templates/course_edit.html.
  • Editing helpers: arrange.rs (lesson table, Undo), word_list.rs + static/word-list.js (Paste a word list), problems.rs (refusal messages), report.rs (feature usage report), lint.rs (course lints), review.rs (Review AI changes).
  • AI generation: ai.rs (LLM client, job runner), ai/queue.rs (the line, slots and leases of AI jobs, #874), ai/prompt.rs (the instructions and each kind of course’s rules), ai/budget.rs (effort levels and the tokens a job reserves, #918), translate.rs (copy and translate a course), metering.rs (Skrongle AI usage and limits), routes/hosted.rs (Skrongle AI on the pages).
  • Sharing: share.rs, routes/share.rs; reports and copyright notices: content_reports.rs, routes/reports.rs.
  • Content check: course_flags.rs (the flag on a made course), course_flags/terms.rs (the listed-term check), course_flags/view.rs (how a page shows matches: masked, templates/flag_matches.html and masked_word.html), list in data/moderation/; content_check.rs (the AI check, stage 1: Jev scores), content_check/jev.rs (the Jev client and the dev stand-in), content_check/review.rs (stage 2: a model reviews what scored high), content_check/nightly.rs (the nightly run), content_check/nightly/zone.rs (local midnight from a POSIX TZ rule), content_check/worker.rs (the checks in their own process, its check-in), content_check/controls.rs (tests only, #1061: the control set in data/moderation/controls/, notes in data/moderation/CONTROLS.md).
  • Themes: themes.rs, data/themes/; course pickers in static/courses.js, static/course-settings.js.
  • Test course: test_course.rs (tests only), data/test-courses/.

Learning modes

  • Lessons and home: routes/pages.rs (/, /lessons, /history, /mistakes), templates/home.html, static/home.js, static/lessons.js; a learner’s note on a lesson: notes.rs, static/lesson-note.js (#336). Music notation in lesson text (#1176): notation.rs (does a lesson have any), static/notation.js draws ```abc blocks with abcjs from static/vendor/ (third-party files, unchanged, with their licences).
  • Questions: quiz.rs (picking, checking), routes/quiz.rs (/api/answer, the one grading path), mathcheck.rs (maths answers), static/quiz.js, static/lesson-check.js.
  • Passes and progress: progress.rs (the pass rule), routes/progress.rs (agent API), mastery.rs (scores), activity.rs (daily goal, resume point), earnings.rs (XP, SKC, streak totals).
  • Motivators: motivators.rs, motivators/xp.rs, motivators/skc.rs, motivators/social.rs, templates/motivators/; the Skrongle Cash shop: motivators/shop.rs, routes/shop.rs, catalogue in data/badges/.
  • Sprint: sprint.rs (fill-the-gap tiles in sprint/gap.rs), routes/sprint.rs, static/sprint.js, static/sprint.css.
  • Study: guide.rs + routes/guide.rs (Guide), tutor.rs + routes/tutor.rs (AI tutor), listening.rs + routes/listening.rs, vocab.rs + routes/vocab.rs, routes/map.rs (/map); scripts static/guide.js, static/tutor.js, static/listening.js, static/vocab.js.
  • Play: games.rs + routes/games.rs (hub, Word Match, numbers, dates), conjugation.rs (drill), static/matching.js, static/conjugation.js, static/numbers.js, static/dates.js.

Accounts

  • Sign-in: auth.rs (sessions, CurrentUser, Project), routes/auth.rs, routes/auth/code.rs (the two-factor step), oauth.rs (Google), proxy_auth.rs (proxy header), signup.rs (who may register), waitlist.rs + routes/waitlist.rs (the waitlist page, #934) and routes/superuser/release.rs (Superuser → Release).
  • Mail and texts: mail.rs (mail/resend.rs, mail/postmark.rs, mail/brevo.rs, mail/sink.rs), sms.rs (sms/phone.rs), verify.rs (address check), reset.rs + routes/reset.rs (password reset).
  • Two-factor: two_factor.rs, totp.rs.
  • Profile: profile.rs, routes/profile.rs, static/profile.js, static/autosave.js, static/avatar-bg/ (picture backgrounds); keys in secrets.rs.
  • Data rights: privacy.rs (export columns, deletion), exports.rs (Download my data), storage.rs and storage/s3.rs (where finished exports live: disk, or the hosted site’s S3 bucket, #871), consent.rs (optional consents for the Privacy and consent card), account_deletion.rs and routes/account_deletion.rs (deleting an account after 14 days, #970).
  • Plans: billing.rs, routes/billing.rs. Terms: terms.rs, routes/terms.rs. Owner and superuser: owner.rs, routes/superuser.rs, routes/superuser/courses.rs and course_flags/table.rs (the course table), course_flags/owner_mail.rs (e-mailing a course’s owner, #1035), moderation.rs (take-downs, suspensions, blocked addresses), breach.rs and routes/superuser/breach.rs (breach tools: sign accounts out, e-mail a notice, #1068), privacy_requests.rs and routes/superuser/privacy.rs (privacy requests by e-mail and their log), routes/password.rs (temporary password gate).
  • Docs pages: routes/docs.rs (/docs sidebar SECTIONS, /llms.txt).

Dev mode only

  • dev.rs + routes/dev.rs (/dev/login, mailbox, stand-in Google; routes/dev/jobs_page.rs is /dev/jobs), dev_ai.rs + routes/dev_ai.rs (/dev/ai, fixtures in data/test-fixtures/), dev_payments.rs + routes/dev_payments.rs (stand-in Stripe), dev_crowd.rs (made-up learners), static/devbox.js.

Elsewhere

  • data/courses/ built-in courses, docs/ these pages, i18n/ app text, e2e/ browser tests, scripts/ tooling, deploy/ systemd and nginx.

Modules

src/
  main.rs        config from env, command line (`doctor`, `--version`), tokio runtime (2 workers), startup, graceful shutdown
  startup.rs     startup errors for the usual self-hosting mistakes: port in use, database folder not writable, secret key missing
  doctor.rs      `skrongle doctor`: read-only diagnostics for bug reports (version, OS, database, schema level, yes/no for secrets and SKRONGLE_LEGAL_EMAIL, disk, memory)
  content.rs     course model: Manifest, Lesson, QuizItem, …; the Theme model and LessonTheme / apply_theme; validation; ContentStore (one course in memory, optionally with a theme merged in)
  themes.rs      theme registry: data/themes/<id>/theme.json + pictures, loaded at boot; learner-made themes in SQLite (owner only, limits)
  courses.rs     course registry: built-in courses from data/courses/, user-made ones in SQLite, themed copies on demand; projects
  ai.rs          LLM client (Anthropic Messages API / OpenAI-compatible, incl. Gemini) + "generate with AI" job runner (generate, resume, revise with feedback, add a lesson)
  ai/
    queue.rs     the line for AI jobs (#874): slots, the `generate_course` rows on the jobs table, the lease heartbeat, going on after a restart
    tests.rs     generation, resume, revision, slots and line tests
  review.rs      "Review AI changes": side-by-side line diff (Hirschberg LCS) of the files the latest AI run changed,
                 JSON shown as labelled text, summaries in words, and Accept minus what was left out (Exclusions)
  arrange.rs     restructure a course by lesson: apply the editor's lesson table (order, units, names, deletions, new and edited lessons), place an AI lesson; the result is one Undo step; the digest of written lessons, the course map and the neighbouring lessons for the next lesson's request
  report.rs      the editor's feature usage report (#328): per unit and lesson, which question types and lesson features it uses
  lint.rs        course lints (#445): cheap checks, no AI, for lessons that load but teach badly; shown at the top of the feature usage report
  db.rs          rusqlite connection, pragmas, migrations, spawn_blocking wrapper
  auth.rs        argon2id hashing, sessions, CurrentUser (route guard) and Project (user + active course) extractors
  oauth.rs       "Continue with Google": OpenID Connect code flow with PKCE; sign-ups waiting for the Terms confirm step (in memory)
  mail.rs        e-mail: the outbox (table mail_outbox), the provider chain with fallback and cooldown, the sender the job runner drains (`mail::Outbox`), SMTP and log providers, SKRONGLE_MAIL_* config
  mail/resend.rs the `resend` kind: Resend's HTTP API (POST /emails, idempotency key per outbox row)
  mail/postmark.rs the `postmark` kind: Postmark's HTTP API (POST /email, Server API token, message stream, outbox id as metadata)
  mail/sink.rs   the dev mailbox: an SMTP server on 127.0.0.1 that keeps what it receives in memory, decoded (/dev/inbox)
  mail/brevo.rs  the `brevo` kind: Brevo's transactional HTTP API, response → Rejected/Unavailable, idempotency key per outbox row
  proxy_auth.rs  sign-in through a reverse proxy's identity header (SKRONGLE_PROXY_AUTH_HEADER / _FROM, #642): a middleware before every handler
  signup.rs      who may make an account (SKRONGLE_SIGNUP: open / invite / closed, #634): the first account is the owner, through the owner link written to the log at startup, invitation links (table signup_invites, the token hashed), the Superuser page's Accounts part and invitation links (routes/superuser.rs)
  waitlist.rs    the waitlist for a slow release (#934, hosted only): the switch (release_settings, read per request by a middleware into a task-local for templates; open acts as invite while on), joining with double opt-in, e-mailed invitations tied to one address (signup_invites.email), users.beta; pages in routes/waitlist.rs and routes/superuser/release.rs
  content_reports.rs  content reports and copyright notices (#968): table content_reports, the course a notice names, who the operator is (SKRONGLE_LEGAL_EMAIL or the superusers), the operator's and the owner's messages
  account_deletion.rs  the learner's own account deletion after 14 days of grace (#970): users.delete_at, signing out, the dated mail, the hourly job housekeeping.deletions that deletes due accounts
  share.rs       sharing a course with other learners by e-mail address (#451): invitations (table course_shares, the link's token hashed), who may open a shared course, the message
  verify.rs      e-mail verification: link tokens (table email_verifications, SHA-256 only), confirm, the message, the link's host (SKRONGLE_PUBLIC_URL or the request)
  reset.rs       password reset links (table password_resets, SHA-256 only, 1 h, single use), the "you already have an account" message, which unconfirmed accounts a registration replaces (SKRONGLE_UNCONFIRMED_DAYS)
  sms.rs         text messages: the outbox (table sms_outbox) with per-account, per-number and server-wide limits counted from the sms_sends ledger, a country allow-list, the same chain-with-fallback design as mail.rs, the sender the job runner drains (`sms::Outbox`), the log and generic HTTP providers, SKRONGLE_SMS_* config
  sms/phone.rs   the dev phone: the `dev-phone` provider dev mode uses without SKRONGLE_SMS_PROVIDERS, keeping the texts in memory (/dev/phone)
  totp.rs        time-based one-time passwords: RFC 6238 TOTP over RFC 4226 HOTP (SHA-1 written out, SHA-256/512 via sha2), base32, the otpauth://totp/ Key Uri Format and its QR code as inline SVG (the qrcode crate, no image feature); the dev authenticator (/dev/authenticator) reads its keys with it
  two_factor.rs  two-factor sign-in: the method in use, the phone number's texted check, the authenticator app's enrolment, sign-in challenges, the proof for turning it off (tables totp_keys, phone_verifications, two_factor_challenges, two_factor_confirmations)
  terms.rs       Terms of Service: the source (built-in text or SKRONGLE_TERMS file), the built-in changelog (docs/terms-changes.json) and the version in effect, the notice of a newer version (minor or material), acceptances, the 30-day e-mail of a material change, sent from /superuser (table terms_announcements)
  billing.rs     paid subscriptions: who is premium (Stripe subscription or grant; everyone with SKRONGLE_PLANS=off, plans_on), RequirePremium, webhook signature + events, Stripe Checkout/Portal calls
  profile.rs     user profiles: the eight named pictures (and the old ids as aliases), name/e-mail validation, profile persistence
  secrets.rs     learners' AI API keys: master secret, ChaCha20-Poly1305 sealing, per-user storage; the sealed server key
  metering.rs    Skrongle AI: the server's key, model per purpose, price table, ai_usage rows, monthly allowance (none with SKRONGLE_PLANS=off), credits, job reservations, daily cap, rate limit; Meter::call, the gate every model call goes through (docs/pricing.md)
  server.rs      the listener: header and body timeouts, a connection cap (axum::serve plus limits)
  security.rs    security headers and the cross-site (CSRF) check on every request, client address, rate limits
  tutor.rs       AI tutoring: in-memory chat sessions, exercise picking, prompts, the reply markers ([[ask]], [[skip]], [[answer: …]]), the model call thread
  mastery.rs     mastery score update + decay, progress/history persistence (per course)
  activity.rs    activity from every mode (#558): the daily goal's effort points per kind (tables activity_log), the lesson every mode resumes at (lesson_touches)
  notes.rs       a learner's own note on a lesson (#336): "My note" on the lesson page, POST /api/lessons/{slug}/note (table lesson_notes)
  earnings.rs    what a learner earned in a course: a window's tally from the log (a run, today), the running totals and the day streak as one `earnings` row per course (#589) that activity::record moves; priced by the motivators' rates
  motivators.rs  motivators (#559): how progress is dressed up (projects.motivator, per course since #574); each draws the course home's top from the shared measures; registry ALL, default "stats"; motivators/social.rs: Social (#563), anonymous cross-learner comparisons, cached per course; motivators/xp.rs: XP and the character level (#561); motivators/skc.rs: Skrongle Cash (#562); motivators/shop.rs: the Skrongle Cash shop (#569), badges per course in motivator_purchases, catalogue data/badges/
  placement.rs   the placement test (#1106): placement.json parsed and checked against the manifest, one sitting drawn (written questions, else random_per_unit of a unit's multiple-choice quiz items), the answers marked (each unit's tally, the first unit with a miss as the start)
  progress.rs    lesson passes shared by every mode: the rule (seen + a run of >= 70 %), the RULES text for tutors and agents, table lesson_passes
  quiz.rs        adaptive question selection, answer normalisation/checking, tiny PRNG
  problems.rs    what the editor and the file page say when a course file is refused: kind, lesson, field, line, message, fix hint (every ContentError and arrange.rs refusal)
  course_flags.rs the content flag of a made course (#1033, hosted only; table course_flags): the listed-term check (course_flags/terms.rs over data/moderation/terms.txt, whole word, case- and accent-blind), run when the files change (courses::revalidate, courses::load_ready_files) and before a share; a flagged course doesn't load, so it doesn't start or open for sharees. The AI scan records through it too
  content_check.rs the AI content check, stage 1 (#1036, hosted only; tables content_scores, content_category_scores): a made course split into parts (a lesson, or pieces of a long one), each scored by Jev (content_check/jev.rs, POST /v1/systemone over ureq) on one yes/no question per category of CATEGORIES (#1046: misinformation, violence, self-harm, weapons, firearms, hate, tragedy, adult, minors, sexual crimes, Terms of Use), all in one call; a part's hash and model cached so an unchanged part isn't sent again; a part at a category's threshold or above is queued for stage 2 (review = 'queued', a `content_check.review` job) and the course shows as needs review in the superuser course table while it waits. Run by the `content_check.scan` job when an AI generation or revision finishes, and before a share (its own thread, waited for up to 20 s). No routes or templates
  content_check/review.rs the AI content check, stage 2 (#1037, hosted only): the queued parts with their scores, the course title and languages, its lesson list and any override note go to the hosted model (ai::chat, purpose content-review); it may read up to 2 more batches of lessons, then answers JSON (verdict harmful | fine, the rule, exact quotes, a 2 to 4 sentence report). Harmful: course_flags flagged, source ai, the report as reason and the quotes located to file and field. Fine: the parts marked 'fine' on their hash. settle() clears an AI flag whose harmful parts are gone (run by every scan). Calls recorded in ai_usage under the owner but kept out of their month (Meter::record_own_cost, migration 76); waits past the daily cap. No routes or templates
  content_check/worker.rs `skrongle content-check` (#1041, hosted only; table content_check_worker): the checks in their own process beside the server, on the same SQLite file. The server with SKRONGLE_CONTENT_CHECK=external queues the content_check.* jobs and its jobs thread doesn't register them; the worker registers only those, polls the jobs table every 2 s, checks in every minute, and hands a course it flagged back as a courses.flagged job (the server drops it from its cache). Process setup (settings, dev stand-ins, the meter) in src/check_worker.rs. The whole content_check module reaches only db, jobs, content, courses, course_flags, ai, metering and mail; a test fails on any other crate path or on axum, askama or the routes
  content_check/nightly.rs the nightly content check (#1038, hosted only; table content_checks): at midnight in SKRONGLE_CONTENT_CHECK_TZ (default America/Vancouver; nightly/zone.rs reads the zone's rule from /usr/share/zoneinfo, no tz crate), the made courses edited since their last scan (oldest edit first, at most SKRONGLE_CONTENT_CHECK_PER_NIGHT) go through the term check, stage 1 and stage 2 in place; a failure is queued as a scan job; then, within the same cap, the unedited courses whose AI check is SKRONGLE_CONTENT_CHECK_RECHECK_BEHIND or more versions old are rechecked, oldest check first (#1040). No routes or templates
  moderation.rs  what a superuser does to keep the Terms (#967, hosted only): take a course down (courses.taken_down_at), suspend an account (users.suspended_at), block an address or range (blocked_ips, kept in memory and checked on every request before the session lookup)
  privacy_requests.rs  privacy requests that came by e-mail (#981, hosted only): the privacy log (privacy_actions) and forgetting an address with no account (its course_shares and mail_outbox rows)
  breach.rs      breach tools (#1068, hosted only): sign every account or a list out (sessions, two_factor_challenges, unused password_resets), and e-mail a plain-text notice through the outbox, once per account and text (breach_notices); both logged in privacy_actions
  owner.rs       what the owner does to an account with no message sent (#635): a temporary password (users.must_change_password), two-factor off, make a superuser; the binary's commands (reset-password, disable-2fa, make-owner, help)
  privacy.rs     a learner's data as a whole: USER_TABLES, the exported columns (EXPORTED/WITHHELD), account deletion, retention rules, the age confirmation (docs/data-inventory.md)
  jobs.rs        background jobs (table jobs, #872): enqueue, the one-statement claim with a lease, retries with per-kind delays, periodic kinds that queue their next run, sliced kinds that queue themselves again (`Step::Again`); the `jobs` runner thread, which also drains the mail and text outboxes with a lease (#873); the hourly housekeeping as one job per purge
  exports.rs     "Download my data" in the background (table data_exports): the `export` job (jobs.rs runner, #875) writes the file page by page to <db>.exports/, a slice per run, mails a one-time link, retention
  storage.rs     where finished files live (#871): `Storage::Disk` (mini, dev) or `Storage::S3` (hosted, SKRONGLE_S3_BUCKET); storage/s3.rs is a hand-written S3 client (PUT, GET, DELETE, pre-signed GET; SigV4 on ureq + sha2; keys from the environment or the instance role via IMDSv2)
  mathcheck.rs   numeric and expression answers: a small parser/evaluator, numbers in every spelling, equivalence by sampling
  guide.rs       stage progress and stars for the Guide (table journey_progress)
  listening.rs   listening practice: dealing sentences, word-by-word grading, the finished round
  vocab.rs       terms collected from passed questions + flashcard review stats, word lookup
  games.rs       Word Match dealing/replay, timed sessions, leaderboard + activity persistence
  conjugation.rs paradigm drill: unlocked items, prompt dealing, answer checking
  sprint.rs       Sprint mode: session building, exercise grading, streaks and XP
  i18n.rs        the app's own text: i18n/<code>.yaml compiled in, the request's language, t / t1 / tn
  random.rs      random bytes from the operating system (getrandom), in one place
  translate.rs   copy and translate a course (#630): which texts the AI translates, one file's request and reply, putting the translations back
  error.rs       AppError -> HTTP responses (incl. redirects to /login and /courses)
  dev_ai.rs      the dev AI provider's logic (#297): key profiles (platform, learner), usage limits and their quota errors, injected failures, the fixture reply for a wire request, Manual replies (held requests), the call log; in memory
  dev_payments.rs the dev payment provider (#314): a Stripe stand-in's state (prices, customers, Checkout sessions, subscriptions, invoices and one-off payments, events, the log) and its rules (pay, decline, renew, fail a renewal, cancel, refund), each returning the events to deliver; dev mode's Stripe settings; in memory
  dev_crowd.rs   a crowd of made-up learners with weeks of backdated history (SKRONGLE_DEV_SEED_USERS, #583), written through the modes' own progress functions; deterministic per learner
  dev.rs         local testing (SKRONGLE_DEV): the gate, the dev user (seed with the simulator's keys, reset), SKRONGLE_LLM_MODE and the dev AI provider's default base URLs, the fixture directory and its in-memory edits, held requests on disk (Manual replies on /dev/ai), the stand-in Google (mock_google: codes, ID tokens, the failures it can act out)
  test_course.rs tests only: keep the test course covering every feature (schema, aliases, quiz types, exercises, themes)
  routes/
    mod.rs       axum Router, embedded static assets and docs, CourseView for templates
    courses/     the course routes, one file per feature (handlers keep their names: `courses::picker`, …)
      mod.rs       shared imports and the module list; re-exports every handler
      picker.rs    GET /courses: the course list, its cards and `?notice=` keys, the editor's notice keys
      settings.rs  POST /courses/{id}/settings (the gear dialog and the first-visit wizard), POST /courses/open/{id}
      stat.rs      GET /courses/{id}/stat/{metric}: a card chip's modal body (what is next), fetched as it opens
      create.rs    POST /courses/new (a draft), /courses/{id}/copy, /courses/{id}/translate (copy and translate with AI)
      editor.rs    GET /courses/{id}: the editor (lesson table, Use AI form, review modal, Undo/Redo, Share links), `owned`
      files.rs     GET/POST /courses/{id}/file, file/delete, publish, delete, export.json; GET/POST /api/courses/{id}/files, GET /api/courses/{id}/file, POST /api/courses/{id}/preview-answer
      generate.rs  POST /courses/{id}/generate (its Generate dialog) and /cancel, provider and quota checks, GET /api/generate/{job}
      revise.rs    POST /courses/{id}/ai (add or revise with AI)|undo|redo|review (Accept in the review: what is left out), POST /api/review-quiet
      lessons.rs   POST /courses/{id}/lessons (JSON: the whole table) and /api/courses/{id}/lessons (the same, the editor's autosave: `session` + `base` version, 409 when behind or busy, 422 refused)
      tests.rs     the editor autosave, notices, write limits, prompt boxes and preview-answer tests
    share.rs     POST /courses/{id}/share (invite addresses) and /courses/{id}/share/remove (owner only); GET /courses/shared/{token} (the invitation's link: signed in, the course joins the list and opens; signed out, /login?share=<token>)
    docs.rs      /docs (sidebar sections: SECTIONS), /llms.txt (docs/llms.txt), /privacy (docs/privacy.md)
    reset.rs     GET/POST /reset (ask for a link: the same answer for any address; ?token=: choose a new password, which ends every session; 404 without mail)
    auth.rs      /login /register (with mail: always "check your mail", signs nobody in) /logout /auth/google… (incl. /auth/google/confirm: a new Google account accepts the Terms), GET /verify-email?token= (confirms the address; signed in or not)
    auth/code.rs /login/code… (the two-factor step: code form, resend, cancel, status, dev device)
    terms.rs     /terms, /terms.md (public), /terms/accept (the gate for accounts without the current version, and the notice's accept form), /terms/dismiss (closes a minor change's notice)
    account_deletion.rs  /account/deletion (the only page of an account scheduled for deletion: its date, Cancel deletion, Log out)
    password.rs  /password/change (the gate after signing in with a temporary password: the new one twice, no old one)
    superuser/courses.rs  the course table on /superuser (#1034, hosted only): every made course 25 to a page, searched by title, id or owner (course_q), filtered (course_show: all | flagged | review | overridden | down), flagged first, then needs review (course_flags/table.rs); a row's detail dialog with what matched and the actions; POST /superuser/courses/override|clear-override|delete|email-owner (the last sends the owner_mail draft as edited, by hand only, #1035)
    reports.rs   POST /courses/{id}/report (a shared course), GET|POST /legal/copyright (public; the contact address without mail), POST /superuser/reports/forward|resolve
    superuser.rs /superuser (superusers only, 404 otherwise: every account 25 to a page with Temporary password, Turn off two-factor and Reset email verification per row; invitation links under SKRONGLE_SIGNUP=invite; Skrongle AI today, everyone, against the daily cap; while a material terms change is coming, a notice and Send the announcement; on the hosted site Suspend and Delete account per row, the course table and blocked addresses, #967, and Privacy requests with their log, #981, superuser/privacy.rs), POST /superuser/reset-email-verification|invites|invites/revoke|temp-password|two-factor-off|terms-announce|courses/take-down|courses/put-back|suspend|unsuspend|delete-account|block-ip|unblock-ip|privacy/export|privacy/forget
    superuser/release.rs /superuser/release (#934, hosted only): the waitlist switch, counts and list, POST /superuser/release/switch|invite|invite-next
    waitlist.rs  POST /waitlist (join: a confirmation link is mailed, the same page either way), GET /waitlist/confirm; GET / is the waitlist page for anyone signed out while it is on
    profile.rs   /profile (picture, personal information, account, subscription with the plans and cancel modals, Skrongle AI this month as a bar (Plus), AI API keys with the own-keys pill (Plus), ), POST /api/profile (autosave), POST /profile/password|api-keys…|own-keys|verify-email/resend, POST /profile/export (queue "Download my data"), GET /profile/export/download?token=… (the mailed link), POST /profile/delete
    dev_payments.rs /dev/payments/stripe/v1/* (Stripe's API), the hosted Checkout and Billing Portal pages, the dev mailbox's Payments section; delivers signed webhooks to /billing/webhook on a thread of its own
    billing.rs   POST /billing/cancel (reason + feedback; Stripe ends it with the month), /billing/checkout (a plan from the modal; a paying learner goes to the portal), /billing/credits (on the account's one Stripe customer, named by its billing_ref), /billing/portal (redirects to Stripe), /billing/webhook (Stripe's signed events); none of them exist with SKRONGLE_PLANS=off
    hosted.rs    Skrongle AI on the pages: who is offered it (hosted_offer: the plan includes some, or credits), whether own keys may be used (own_keys_usable: Plus only with the pill on; always with plans off), the provider for one call, "Skrongle AI left this month: 83 %" (not with plans off), OutOfAi (upgrade / pack / own key buttons when it does not cover a job)
    pages.rs     / (landing for visitors, home: the three modes) /lessons /lessons/{slug} /history /mistakes, POST /api/lessons/{slug}/note (the lesson note, autosave)
    map.rs       /map
    quiz.rs      /practice, POST /api/answer (grade_and_record: the one grading path for every mode), POST /api/lesson-check (the reading check)
    progress.rs  POST /lessons/{slug}/read (lessons without questions), POST /api/practice/finish (runs + the reward line), the agent API: GET /api/progress[/lessons/{lesson}], POST /api/progress/seen|answers|pass
    tutor.rs     /tutor, GET /api/tutor/session, POST /api/tutor/start|turn|cancel
    guide.rs     /guide, /guide/{slug}, POST /api/guide/step|restart
    listening.rs /listening, /listening/play, POST /api/listening/check|finish
    vocab.rs     /vocabulary, /vocabulary/study, /lookup, POST /api/vocabulary/review
    games.rs     /play (Play mode hub; /g and /games redirect here), /play/matching…, /play/conjugation…, /play/numbers, /play/dates, their APIs
    sprint.rs     /sprint, /sprint/play, POST /api/sprint/check|finish
    dev.rs       /dev/login, /dev/reset, /dev/mail, /dev/jobs, /dev/inbox, /dev/phone, /dev/sms, /dev/authenticator, /dev/google/auth + /dev/google/token (the stand-in Google), /dev/boot (the --watch reload poll); all mounted only in dev mode and for local clients, see testing.md; the dev mailbox needs no sign-in
    dev_ai.rs    /dev/ai: the dev AI provider's wire API (Anthropic Messages, OpenAI chat completions, Gemini's OpenAI endpoint; checked by API key; a held request waits for its reply file) and its page in the dev mailbox (log, limits, failures, Manual replies with the held requests and their reply forms, fixture editor)
templates/       askama templates (compiled in)
static/          CSS + dependency-free JS (compiled in); vendor/ holds the one third-party script, abcjs
docs/            this documentation (compiled in, served at /docs)
data/courses/    built-in courses: <id>/course.json + lessons/**.json + pictures/**.svg
data/test-courses/  courses for automated tests, loaded only with TEST_COURSE=1 (see testing.md, "The test course")
data/themes/     themes: <id>/theme.json + pictures/**.svg
data/test-fixtures/llm/  canned model replies the dev AI provider answers from, one <purpose>.json per call site
deploy/          systemd unit + nginx snippets
scripts/         release.sh (one-shot), build-pi.sh (cross-compile), deploy.sh, dev-progress.sh, smoke.sh (local smoke test)

Request flow

Every handler is async; all SQLite and argon2 work goes to Tokio’s blocking pool (Db::call) so the two async workers never stall. Course generation runs on its own OS thread (it is minutes of HTTP waiting) and writes through Db::with.

Background jobs (src/jobs.rs, #872). One jobs table and one OS thread (jobs, not the Tokio blocking pool) that runs due rows. Taking a row is one UPDATE … RETURNING that sets state = 'running' and a lease (locked_by, locked_until, 10 minutes); a running row whose lease ran out is taken again, so a job its server stopped in the middle of is not lost. A failure goes back to queued after its kind’s delay (the mail outbox’s RETRY_DELAYS unless the kind says otherwise) until max_attempts, then failed with last_error; each failure is a log line, and so is a job of an unknown kind, which is refused. A periodic kind queues its next run when a run ends. The thread wakes on AppState::queue or when the next row is due, at least once a minute, and reaches SQLite through Db::with. The first user is the hourly housekeeping, one kind per purge (housekeeping.sessions, .games, .ai_jobs, .tutor_chats, .rate_limits, .retention, .outbox, .links, .exports, .jobs, .deletions, .wal_checkpoint), each holding the database for its own short piece. At boot each gets a row due now if it has none, so a server restarted more often than hourly still cleans up. content_check.scan (payload {"course": id}, #1036) scans one made course with Jev; it is queued when an AI generation or revision finishes (courses::finish_generation, finish_revision) and is registered only on a server that scans (not Skrongle mini). A scan that leaves a part queued queues content_check.review (payload {"course": id}, #1037), registered only when the server also has Skrongle AI; Share wakes the runner so it starts at once. content_check.nightly (#1038) is periodic with at instead of every: its first row and each next one are due at the next local midnight (content_check::nightly::next_midnight), not at boot. With SKRONGLE_CONTENT_CHECK=external (#1041) the server registers none of the content_check.* kinds: skrongle content-check, a second process on the same database, registers only those and takes them with the same claim and lease. SQLite’s write lock makes the one-statement claim safe across the two processes (WAL, busy_timeout 5 s on every connection); a worker stopped mid-check leaves a running row that is taken again after its lease. Nobody wakes the worker, so it looks every 2 s. A course it flags comes back to the server as a courses.flagged job (payload {"course": id}), which drops it from the server’s course cache within a minute. Long work runs in slices so the one runner is never held for long: the handler returns Step::Again with its place as the new payload, and the row goes back to queued, due now, attempts reset, behind any row already due. Data exports work this way (kind export, #875): each run writes 50 pages, then saves the table, the last key and the good byte count; after a stop, the lease runs out, the next run cuts the part file back to that byte count and goes on. The same thread sends the mail and text outboxes (#873): they keep their own tables (mail_outbox, sms_outbox) but take a row with the same kind of lease (jobs::lease_outbox sets locked_by and locked_until; the row stays queued), so a row being sent is never handed out twice and one whose server stopped mid-send is sent again once its lease is over. Each pass sends up to 10 rows per outbox before the next job, outboxes first, so a sign-in code never waits behind housekeeping. AI generation still has its own thread. /dev/jobs lists the job rows in dev mode; /dev/mail and /dev/sms show a leased row as sending.

Proxy sign-in. With SKRONGLE_PROXY_AUTH_HEADER and _FROM set, proxy_auth::middleware runs before every handler (not on /dev/*, /static/* or /logout): a request whose TCP peer is in the list and that carries the header gets a session for the account with that address (or one made per SKRONGLE_SIGNUP), written into its Cookie header, so the extractors below see it as any other session. See self-hosting.

Extractors. CurrentUser resolves the session cookie (browser routes get redirected to /login, /api/* routes get a 401). The same query says whether the user accepted the current Terms (terms::version(): the newest entry of docs/terms-changes.json in effect, or the hash of the SKRONGLE_TERMS file) and which version they accepted last. An account behind on the built-in text gets CurrentUser::terms_notice, which base.html shows as a modal (terms_notice.html): for a minor change the request goes through; for a material one GET pages go through with the notice over them, /api/* gets a 403 and anything else (but POST /profile/delete) goes to /terms/accept. An account that never accepted, or any account behind on a SKRONGLE_TERMS file, is sent to /terms/accept?next=<path> (/api/*: 403). Before that, an account signed in with a temporary password the owner set (users.must_change_password, #635) is sent to /password/change?next=<path> the same way (/api/*: 403); that page and /terms/accept take MaybeUser. MaybeUser (pages that work logged out) does not check. Both set the request’s language to the account’s users.lang (see i18n); before them, the i18n::middleware layer picked one from the lang cookie or Accept-Language. Project adds the active course: users.active_course → the registry (Courses::get) → an Arc<ContentStore>, and start, the unit index of the level the learner started that course at (projects.start_unit, read in the same session query). No active course, or one that no longer loads, sends browser routes to /courses and API routes a 410, which the page scripts answer by going to /courses too (#1083).

Courses. Built-in courses are loaded once at boot from data/courses/<id>/; the folder name must equal the manifest id. User-made courses are rows in courses with their files in course_files; the first request for one builds a ContentStore from those files and caches it in memory until the next edit (Courses::invalidate). Both go through ContentStore::from_files, so validation is identical. A course’s non-JSON files (the SVG pictures under pictures/) are kept in the ContentStore as text and served by /pictures/{name} for the active course, with an ETag and a content-security policy that forbids scripts. A user-made course is visible to its owner, and read only to whoever the owner shared it with (share::can_open; features): POST /courses/open/{id} checks both, the editor and file routes the owner only.

What the server can send. AppState::caps() is a Caps { mail, sms } (Mailer::can_send, Texter::can_send, fixed at boot): false when the channel’s chain is the log provider alone; dev mode’s mailbox and phone count as providers. Features ask it, not the mode, and hide what would send a message (#633); templates that need it carry a copy (caps, or a mail flag). Self-hosting lists what changes.

Themes. AppState.themes holds every theme under data/themes/ (themes::Themes, loaded at boot). A theme is a course’s setting: projects.theme (‘’ or NULL for Default; #565, #577). resolve_user wears the active course’s one on every page except /courses and the pages under it, and Default outside a course (CurrentUser.theme is the theme worn; users.theme is no longer read). The profile picture is per course too: CurrentUser.avatar and avatar_color are the active course’s (projects), and no_picture is set where no course is in context (the same pages), whose badge shows a gear (templates/avatar_gear.html). CurrentUser carries it and Project asks the course registry for get_themed(course, theme), which builds (once, then caches) a ContentStore with that theme’s lesson material merged in (content::apply_theme) and its strings swapped. Everything downstream is unchanged: handlers see themed lessons, and Sprint mode’s grading resolves exercise ids against the same themed store. GET /theme.css?t=<id> serves the custom properties of the theme the page wears (without t, the worn one) (hex colours and a font stack only, checked at load), linked after the app’s stylesheets on every page; GET /themes/{id}/{path} serves a theme’s pictures; POST /courses/{id}/settings switches a course’s. CurrentUser resolves the worn theme’s version of the learner’s avatar to a URL (theme_avatar_url, profile::themed_avatar), so the top bar shows it on every page and the plain icon returns when the theme comes off; the picture choice itself never changes. An invalidated course drops its themed copies too.

Themes learners make are the same files (theme.json, pictures/*.svg) stored in user_themes / user_theme_files and loaded through the same validator (ThemePack::from_files). They are worn as user-<n> (n = the row id; built-in theme ids may not start with user-). Themes::resolve(db, user, id) is the one lookup: a built-in theme, or a made one only for its owner (anyone else gets “no such theme”, so a 404 for its pictures and Plain if a course of theirs names it); made themes are cached after the first load and forgotten on edit or delete. CurrentUser.theme_pack is the worn theme, resolved once per request. Deleting a worn theme sets the projects.theme that named it back to ‘’. Limits per account: SKRONGLE_THEMES_PER_USER (5) and SKRONGLE_THEME_BYTES (524288, all files of all their themes). Without SKRONGLE_THEMES_ENABLED=1 the store is Themes::off(): empty, resolve finds nothing (made themes included) and create refuses, so every page falls back to Default without a check of its own. There is no editor yet; Themes::create / replace / delete are the storage API it will use.

Templates are server-rendered. The small scripts (quiz.js, guide.js, sprint.js, …) post JSON to /api/* and render feedback inline. Everything that talks (Numbers by ear, Date Dash, Listening, Sprint mode) shares speech.js, a wrapper over the browser’s speech synthesis: voice choice per language, remembered in localStorage, the site-wide audio switch (speech:audio), say(text, lang) and the cancel-then-settle sequence Chrome needs. base.html loads it on every page together with say.js, which makes the words of a language course tappable (read aloud) wherever the mode bar carries data-say-lang, and with modal.js, which closes any modal on a click on its backdrop (#226): a <dialog> opened with showModal(), or a server-drawn .modal-backdrop overlay (by following its [data-modal-close] link). A modal stays when it or its backdrop has data-modal-static (a running AI job’s box), or while a field of a form in it differs from what it was drawn with (a password half-typed, a wish not sent yet). New modals need nothing to get this. Course specifics the browser needs (term/meaning labels and languages, helper characters, praise strings) arrive as data- attributes or inside the Sprint mode session JSON, never hard-coded.

SQLite schema

users          (id AUTOINCREMENT, password_hash, created_at, google_sub UNIQUE, email,
                active_course, first_name, last_name, avatar, avatar_color, theme, age_confirmed, developer, ai_off,
                email_verified_at, superuser, phone, phone_verified_at, two_factor, lang, billing_ref UNIQUE, stripe_customer,
                must_change_password, suspended_at, beta)
                -- id is never reused after a deletion (AUTOINCREMENT since v45, #364);
                -- billing_ref: random per account, what Stripe's subscription metadata names (never the id);
                -- stripe_customer: the one Stripe customer every Checkout of the account shares (#394);
                -- password_hash is '' for Google-only accounts; email is what a password account
                -- signs in with (no usernames since v26), set once and never changed;
                -- email_verified_at: when the address was confirmed (link or Google), NULL = not yet;
                -- avatar is an id from src/profile.rs (AVATARS), shown in the worn theme's version;
                -- avatar_color a background slot: 1-8 the patterned palette (profile::palette, the theme's avatar_colors), 9-16 a theme's scenes (avatar_backgrounds), 0 = none picked yet (profile::DEFAULT_COLOR, v35);
                -- theme a theme id from data/themes/ or '';
                -- lang the language the app speaks to this account in (i18n/<code>.yaml, v42), '' = the browser's;
                -- developer 1 marks a developer account (set by hand or the dev seed); nothing reads it since #298
                -- ai_off 1 hides every feature that calls an AI provider and makes its routes refuse (#147)
                -- superuser 1 opens /superuser and links it from the account menu (by hand, make-owner, or the first account under SKRONGLE_SIGNUP=invite|closed; the dev user in dev mode)
                -- phone: E.164 or ''; phone_verified_at: when a texted code confirmed it (v39);
                -- phone_opted_out_at: a provider refused a text to it as opted out (STOP, v69, #969);
                -- cleared when a text to it goes through or the number changes; codes go by e-mail meanwhile
                -- two_factor: the second sign-in step, '' (off) | 'sms' | 'email' | 'totp' (src/two_factor.rs)
                -- must_change_password 1: signed in with a temporary password the owner set (src/owner.rs, v62);
                -- CurrentUser sends the account to /password/change until it picks its own
                -- suspended_at: a superuser suspended it (src/moderation.rs, v70): no session resolves, sign-in is refused, its courses are not shared out
                -- beta 1: made through an e-mailed waitlist invitation (src/waitlist.rs, v86, #934); nothing reads it yet
                -- delete_at: the learner asked to delete it; deleted then by housekeeping.deletions (src/account_deletion.rs, v88, #970);
                -- CurrentUser sends it to /account/deletion until then, its courses are not shared out
sessions       (id, user_id, session_token UNIQUE, created_at, expires_at)
                -- session_token is the SHA-256 (hex) of the cookie's token (auth::hash_token), never the token
user_themes    (id, owner_id, title, created_at, updated_at)       -- themes learners make; worn as 'user-<id>'
user_theme_files (theme_id, path, content)  PRIMARY KEY (theme_id, path)   -- theme.json and pictures/*.svg
courses        (id, owner_id, title, created_at, updated_at, error, state, source, visibility, job_id, brief, outline, notes, taken_down_at)
                -- user-made courses; state 'generating' | 'revising' | 'ready' | 'failed', source 'hand' | 'ai',
                -- visibility 'private' (publishing to come); brief/notes/outline let a stopped AI run resume
                -- taken_down_at: a superuser took it down (v70, #967): it opens for nobody, its owner's card says why
course_files   (course_id, path, content, updated_at)  PRIMARY KEY (course_id, path)
course_undo    (course_id, seq, title, outline, label, created_at, session, started_at)  PRIMARY KEY (course_id, seq)
course_undo_files (course_id, seq, path, content)  PRIMARY KEY (course_id, seq, path)
                -- the files from before each change to a course as a whole (feedback, new order,
                -- lesson removed or added), for Undo: seq > 0, newest (highest) first, ten per
                -- course at most. Redo steps share the table with seq < 0 (newest = lowest): an
                -- Undo files the course as it was there; any new change clears them;
                -- `session`: the editor page whose saves join this step (for ten minutes from
                -- `started_at`; `created_at` is then its latest save)
course_ai_runs (course_id PRIMARY KEY, label, created_at, excluded, edits)
course_ai_run_files (course_id, side, path, content)  PRIMARY KEY (course_id, side, path)
                -- the latest AI run's changed files, side 'before' | 'after' (missing: added or
                -- removed), for "Review AI changes" (src/review.rs); the next run replaces it.
                -- `excluded`: what the review left out (JSON `review::Exclusions`, '' for nothing)
                -- `edits`: the review's own text for lines the AI wrote (JSON `review::Edits`:
                -- path → row key → text, '' for none; v41, #287)
course_shares  (id, course_id, email, token_hash UNIQUE, user_id, created_at, opened_at)  UNIQUE (course_id, email)
                -- a course shared with an address (src/share.rs, v43): token_hash is the SHA-256 of the
                -- link's token; user_id the account that opened the link (NULL until then, or when
                -- that account is deleted; the share stays and can be opened again). The course is
                -- open to that account and to any account whose confirmed address is `email`
course_flags   (course_id PRIMARY KEY, state, source, matches_json, reason, content_hash, checked_at,
                override_note, overridden_by, overridden_at)
                -- the content flag of a made course (src/course_flags.rs, v74, #1033): state 'clear' |
                -- 'flagged' | 'overridden'; a flagged course doesn't start or share. source 'terms' (the
                -- listed-term check) | 'ai' (the AI scan, #1036/#1037); matches_json what triggered it
                -- ([{term, path, field, lesson, text}]); reason a short text for the superuser;
                -- content_hash the SHA-256 of the files and term list checked. The override columns
                -- are a superuser's decision (#1034), kept whatever a later check writes
signup_invites (id, token_hash UNIQUE, created_by, created_at, expires_at, used_at, used_by, email)
                -- invitation links for SKRONGLE_SIGNUP=invite (src/signup.rs, v61): token_hash is the SHA-256
                -- of the link's token; one use (used_at, used_by), 7 days; created_by cascades,
                -- used_by is set NULL when the invited account is deleted; gone 30 days after expiry.
                -- email (v86, #934): an e-mailed waitlist invitation, usable only by an account with that address
waitlist       (id, email UNIQUE, joined_at, confirm_hash, confirmed_at, invite_id, invited_at, invites_sent, signed_up_at, user_id)
                -- the waitlist (src/waitlist.rs, v86, #934): email lower case; confirm_hash the SHA-256 of the
                -- confirmation link's token until it is used; invite_id the latest e-mailed invitation (set NULL
                -- when purged); user_id the account it signed up as (cascades); unconfirmed rows go after 30 days
release_settings (id = 1, waitlist)  -- the waitlist switch, set on /superuser/release (#934)
projects       (user_id, course_id, created_at, last_opened_at, start_unit, theme, avatar, avatar_color, motivator, set_up)  PRIMARY KEY (user_id, course_id)
               -- theme, avatar, avatar_color, motivator: the course's settings (#565, #574); avatar/avatar_color and
               -- motivator start as the account's; set_up: 0 until the first visit's settings wizard is left (#574)
user_progress  (user_id, course_id, lesson_id, mastery_score, times_tested, times_correct, streak,
                last_practiced_at, read_at)  PRIMARY KEY (user_id, course_id, lesson_id)
                -- read_at: when the lesson was first read or taught (reading check, or seen in any mode); NULL = unread
quiz_history   (id, user_id, course_id, lesson_id, question_id, selected_answer, was_correct, answered_at)
journey_progress (user_id, course_id, lesson_id, step, completed, best_stars, xp, run_correct, run_answered,
                updated_at)  PRIMARY KEY (user_id, course_id, lesson_id)      -- the Guide; xp unused since #558
user_vocab     (user_id, course_id, lesson_id, word, learned_at, times_seen, times_known, last_reviewed_at)
game_scores    (id, user_id, course_id, game, board, score_ms, wrong, played_at)
game_activity  (id, user_id, course_id, game, board, label, duration_ms, total, correct, score_ms, played_at, xp)
                -- xp unused since #558
activity_log   (id, user_id, course_id, kind, n, at)     -- n of activity::Kind `kind` done at `at`: the daily goal
lesson_touches (user_id, course_id, lesson_id, mode, at)  PRIMARY KEY (user_id, course_id, lesson_id)
                -- when and in which mode a lesson was last opened or worked on: where every mode resumes
                -- idx_lesson_touches_course_at (course_id, at): Social's lesson count (#501)
lesson_notes   (user_id, course_id, lesson_id, body, updated_at)  PRIMARY KEY (user_id, course_id, lesson_id)
                -- the learner's own note on a lesson ("My note", #336, v93); an emptied note has no row
earnings       (user_id, course_id, points, goals, day, day_points, streak)  PRIMARY KEY (user_id, course_id)
                -- running totals (#589): effort points ever logged, daily goals reached, the local day last
                -- active, its points and the streak ending on it; XP, SKC and the streak read this, not the log
                -- idx_earnings_course_day (course_id, day): Social's streak count (#505)
game_rounds    (user_id, course_id, game, rounds)  PRIMARY KEY (user_id, course_id, game)
                -- rounds finished per game (v64), moved by games::record_activity_at: "rounds played" reads this
lesson_passes  (user_id, course_id, lesson_id, seen_at, seen_via, best_pct, best_via, passed_at, passed_via, runs,
                evidence, updated_at)  PRIMARY KEY (user_id, course_id, lesson_id)
                -- via is 'k' | 'd' | 'g' (Study, Sprint, Play: the modes' old letters, progress::VIA_*)
                -- | 'tutor' | 'agent' | 'legacy'; best_pct -1 = no counted run yet
user_api_keys  (user_id, provider, ciphertext, updated_at)  PRIMARY KEY (user_id, provider)
                -- provider is 'anthropic' | 'openai' | 'google'; ciphertext is sealed by src/secrets.rs
subscriptions  (user_id PRIMARY KEY, customer_id UNIQUE, subscription_id, status, price_id, current_period_end,
                cancel_at_period_end, event_at, updated_at, plan)
                -- the learner's Stripe subscription as the last webhook described it; status is Stripe's;
                -- plan is what it pays for ('basic' | 'plus', from its price, never metadata; v55, #314, #963)
plan_grants    (user_id PRIMARY KEY, granted_at, expires_at, note)   -- Plus without paying; expires_at NULL = no end
plan_cancellations (id, user_id, plan, reason, feedback, created_at)   -- why a learner cancelled (billing::CANCEL_REASONS)
terms_acceptances (user_id, version, accepted_at)  UNIQUE (user_id, version)
terms_announcements (user_id, version, mailed_at)  PRIMARY KEY (user_id, version)
                -- who was e-mailed about a material change of the built-in terms (v67, #966)
                -- one row per Terms of Service version a learner accepted; no IP address
consents       (id, user_id, purpose, granted_at, withdrawn_at)
                -- an optional consent (src/consent.rs, v82, #974): 'ai' = AI features; one row per time it was
                -- given, withdrawn_at set when it is taken back. Shown on the profile's Privacy and consent card
mail_outbox    (id, user_id, to_addr, subject, body_text, body_html, purpose, state, attempts, next_attempt_at,
                scheduled_at, created_at, sent_at, provider, last_error, locked_by, locked_until)
                -- e-mail to send (src/mail.rs); state 'queued' | 'sent' | 'failed'; provider = the one that took it;
                -- locked_by/locked_until (v84, #873): the job runner's lease while it sends a queued row
                -- user_id NULL: about no account. Sent bodies emptied (not in dev mode); finished rows kept 30 days.
                -- scheduled_at (v58, #579): when it was first due, i.e. the hour whose SKRONGLE_MAIL_PER_HOUR budget it uses
sms_outbox     (id, user_id, to_number, body_text, purpose, state, attempts, next_attempt_at, created_at, sent_at,
                provider, last_error, locked_by, locked_until)
                -- text messages to send (src/sms.rs); to_number is E.164; otherwise as mail_outbox
sms_sends      (id, user_id, number_key, kind, created_at)
                -- the send limits' ledger (v42, #362): one row per text queued; number_key = SHA-256 of the
                -- number, kind 'signin' | 'other'; user_id ON DELETE SET NULL (the row outlives the account);
                -- purged after two days
jobs           (id, kind, payload_json, state, priority, run_at, attempts, max_attempts, locked_by, locked_until,
                last_error, created_at, finished_at)
                -- background jobs (src/jobs.rs, v72, #872); state 'queued' | 'running' | 'done' | 'failed';
                -- locked_by/locked_until: the lease of a running row. Done rows deleted after a day, failed after 30
content_scores (course_id, chunk, lesson, hash, high, model, input_tokens, cost_usd,
                review, scanned_at, ai_version, PRIMARY KEY (course_id, chunk))
                -- the AI content check's stage 1 (src/content_check.rs, v75, #1036): one row per part of a made
                -- course (`lessons/x.json`, `lessons/x.json#2`…). hash = SHA-256 of the text Jev was sent; the
                -- same hash and model are not sent again. high = the categories it scored high on, comma
                -- separated (v80, #1046); review NULL when none, 'queued' for stage 2, then its verdict 'fine' | 'harmful' (#1037), kept
                -- while the hash stays. Cascades with the course
content_category_scores (course_id, chunk, category, score, PRIMARY KEY (course_id, chunk, category))
                -- Jev's probability per part and category (v80, #1046; the names of content_check::CATEGORIES).
                -- Cascades with its content_scores row
content_checks (course_id PRIMARY KEY, checked_at)
                -- when the content check last read a made course's files (v77, #1038): a course whose
                -- updated_at is at or after it is due for the nightly run. Cascades with the course
content_check_worker (id PRIMARY KEY CHECK (id = 1), name, seen_at)
                -- when `skrongle content-check` last checked in (v79, #1041): one row, name = its pid@start
email_verifications (id, user_id, email, token_hash UNIQUE, created_at, expires_at, used_at)
                -- e-mail confirmation links (src/verify.rs): only the SHA-256 of the token; 24 h, single use;
                -- users.email_verified_at is set when one is opened. Rows deleted after 7 days
password_resets (id, user_id, email, token_hash UNIQUE, created_at, expires_at, used_at)
content_reports (id, user_id, course_id, course_title, location, reason, email, name, work, details, good_faith, created_at, state, forwarded_at, resolved_at)
                -- password reset links (src/reset.rs, v66): only the SHA-256 of the token; 1 h, single use;
                -- using one sets the password, confirms the address, deletes the sessions. Rows deleted after 7 days
totp_keys      (user_id PRIMARY KEY, secret, created_at, confirmed_at, last_step)
                -- the account's authenticator app (v39): the secret sealed like user_api_keys (ChaCha20-Poly1305,
                -- secrets::seal_totp, bound to the user; codes are worked out from it, so it can't be hashed),
                -- confirmed_at NULL while enrolling (purged after a day),
                -- last_step the newest RFC 6238 time step accepted: a code works once
phone_verifications (id, user_id, phone, code_hash, salt, attempts, created_at, expires_at)
                -- the texted code confirming users.phone: SHA-256(salt:code) only; 10 minutes, 5 tries
two_factor_challenges (id, user_id, token_hash UNIQUE, method, code_hash, salt, attempts, created_at, expires_at, sent_by)
                -- a sign-in waiting for its second step: token_hash is the SHA-256 of the `twofa` cookie's token;
                -- code_hash SHA-256(salt:token:code) for texted/e-mailed codes ('' for an authenticator); 10 minutes, 5 tries;
                -- sent_by 'email' when a texted code went by e-mail because the number opted out (v69)
two_factor_confirmations (user_id PRIMARY KEY, method, code_hash, salt, attempts, created_at, expires_at)
                -- a Google-only account's code for turning its texted/e-mailed second step off or switching it:
                -- SHA-256(salt:code), only for the method it was sent for; 10 minutes, 5 tries
ai_usage       (id, user_id, course_id, job_id, purpose, provider, model, input_tokens, output_tokens, cached_tokens,
                cost_micros, credit_micros, key_source, created_at)
                -- one row per model call; key_source 'hosted' (the server's key, costed) | 'own' (cost 0);
                -- credit_micros is the part of the cost paid from credits, the rest came from the monthly allowance;
                -- purpose 'content-review' (#1037) is the server's own call about the owner's course: no credits, and
                -- the triggers leave it out of ai_usage_months (migration 76), so only the day's spend counts it
ai_usage_months (user_id, month, cost_micros, credit_micros, hosted_calls, own_calls)  PRIMARY KEY (user_id, month)
                -- running totals of ai_usage per learner and UTC calendar month (month = its first second); v65, #624
ai_spend_days  (day PRIMARY KEY, cost_micros)
                -- everyone's hosted spend per UTC day (day = its first second), for the daily cap; v65, #623.
                -- Triggers on ai_usage (insert, delete) move both tables; the meter reads one row of each
blocked_ips    (id, cidr UNIQUE, created_at)
                -- addresses and ranges a superuser blocked (v70, #967); read into memory at startup and after each change
privacy_actions (id, superuser_id, action, reason, created_at)
                -- the privacy log on /superuser (v85, #981): export | forget | delete-account | sign-out | notice with the reason typed; no learner named
breach_notices (user_id, notice, mailed_at)  PRIMARY KEY (user_id, notice)
                -- who had which breach notice (v91, #1068); notice = SHA-256 of subject and body, so Send again mails nobody twice
ai_credit_purchases (session_id PRIMARY KEY, user_id, payment_intent, price_id, amount_cents, credit_micros,
                refunded_micros, created_at)     -- Stripe payments for credit packs: applied once, refunds take back their share
users.ai_credits                                 -- the prepaid credit balance, micro-dollars
users.signup_credit_at                           -- when a new account got its one-time Skrongle AI credit (v87, #931); 0 for accounts made before
users.ai_effort                                  -- the effort Generate the course last ran at: low | medium | high (Fast, Balanced, Thorough; v92, #918)
data_exports    (id, user_id, state, token_hash, file, link_base, bytes, error, created_at, ready_at, expires_at, downloaded_at)
                -- "Download my data" (v44, #430): queued → building → ready (link mailed) → downloaded / expired, or failed;
                -- token_hash is the SHA-256 of the mailed link's token; file is its name in <db>.exports/

Migrations live in src/db/migrations.rs (MIGRATIONS), tracked with PRAGMA user_version. Migration 8 added the course scoping: existing rows were assigned to the spanish course. Migration 9 added the profile columns; accounts from before it have no e-mail address until they set one on the profile page. Migration 10 added user_api_keys. Migration 11 added users.theme. Migration 13 added lesson_passes and carried over earlier Guide and Sprint mode completions (seen; passed with two stars or more) and read lessons (seen). Migration 14 gave user-made courses their lifecycle columns (state, source, visibility, the generation job, brief, outline and notes). Migration 15 added subscriptions and plan_grants. Migration 22 added users.age_confirmed (the “I am at least N” part of the Terms box at sign-up, set by registration, the Google confirm step and /terms/accept). Migration 34 stores session tokens hashed: it ends every existing session (everyone signs in once more) and marks accounts that accepted the Terms as age-confirmed. Migration 23 added terms_acceptances; accounts from before it meet the terms gate at their next visit. Migration 24 added users.developer and users.mock_ai (both 0 for every existing account; migration 40 drops mock_ai again, #298). Migration 25 added mail_outbox. Migration 27 added users.ai_off (“Hide all AI features”, 0 for every existing account; setting it also deletes the account’s user_api_keys rows). Migration 29 added users.email_verified_at and email_verifications; existing accounts start unconfirmed. Migration 30 added users.superuser (0 for every existing account). Migration 32 added course_undo.session and started_at (the editor’s autosave, #151/#190; older steps have neither and are never added to). Migration 35 added sms_outbox. Migration 39 added two-factor sign-in: users.phone, phone_verified_at and two_factor (off for every existing account), totp_keys, phone_verifications, two_factor_challenges and two_factor_confirmations. Migration 43 added course_shares (#451), migration 44 data_exports (#430). Migration 45 rebuilt users with AUTOINCREMENT (ids are never handed out again, #364) and added billing_ref (a random value for every account) and stripe_customer (filled from the account’s subscriptions row). Migration 46 added the sms_sends ledger and a unique index on verified phone numbers (users.phone WHERE phone_verified_at IS NOT NULL), undoing the later verification where two accounts had verified one number (#362, #363). Migration 47 added ai_usage, ai_credit_purchases and users.ai_credits (Skrongle AI metering). Migration 48 added the plans bought on the profile: plan_subscriptions, plan_cancellations and users.own_keys_fallback. Migration 49 added subscriptions.plan (which plan a Stripe subscription pays for). Migration 83 dropped plan_subscriptions with the pretend card payment (#963). Migration 59 added the indexes the data export pages and its housekeeping read through (#625, #626). Migration 60 (#614–#620) added the indexes the housekeeping sweeps and deletions read through: quiz_history(answered_at), game_activity(played_at), courses(state, updated_at), courses(state) WHERE notes <> '', projects(course_id), users(active_course) and users(theme) (partial, set values only); db::tests::every_foreign_key_is_indexed keeps every foreign key indexed, so deleting an account never reads a whole table. Migration 61 (v61 in src/db.rs) added signup_invites (#634); migration 62 users.must_change_password (#635, 0 for every existing account); migration 63 the lower(email) index on users (#604, #605, #606, #622); migration 64 the game score and round indexes and game_rounds (#592–#601). What each table holds, and how long, is in the data inventory; a table with user data must be listed in privacy::USER_TABLES (a test checks every table in the schema), and each of its columns in privacy::EXPORTED or privacy::WITHHELD (a test checks those too).

Model calls. Every call site (POST /courses/{id}/generate, POST /courses/{id}/ai, the tutor) uses a saved key (or a pasted one on the generate form) and the provider’s base URL; nothing in the app knows whether the other end is real. There is no mock provider and no stub in ai::complete (#298): in dev mode the default base URLs (ai::ProviderKind::default_base_url, through dev::provider_base_url) point at the dev AI provider on this server, and the dev user’s seeded keys are its keys, so fake replies come only from a key whose base URL points there. Before any of that, every such handler asks user.require_ai() (pages) or user.require_ai_api() (JSON): an account with ai_off set is sent to the profile’s AI features section (AppError::AiOff) or answered 403 (AppError::ApiAiOff), and the pages that link to those handlers leave the links out (user.ai_off in the templates). Every request carries an x-skrongle-call header naming its purpose, key, job and vars (ai::call_header). The providers ignore it. The dev AI provider picks its fixture reply by it. Error statuses are read as responses (http_status_as_error(false)), so a provider’s own message (a used-up quota) reaches ai::plain_failure.

AI jobs on the editor. POST /courses/{id}/generate and POST /courses/{id}/ai start a job and redirect to /courses/{id}?job=<job>; the editor (modal_job) renders ai_job_modal.html for that job, or for the job generating or revising the course when there is no ?job=, except a generation that succeeded (the editor is its result). The generator writes the row before it marks the job done, so the editor reads the row again when it saw a busy row and a done job (settled, #183); a row that still says busy gets the finished job’s modal, never a “being generated” page with nothing to refresh it: a generation’s with Continue, an Only add or Allow changes job’s still following (AiJobModal::follows), so the reload opens the review (unless users.review_quiet: then a notice says the AI has finished, and Review changes opens it). courses.js polls /api/generate/{job} and reloads with ?job= when it is done. /courses/generate/{job}, the old status page, redirects there.

Accepting the review. The AI’s changes are in the course as soon as the job ends; the review lets the learner take parts back out. Its form posts file=<path> per lesson left out and lines=<path>:<key>,… per group of rows (key “3-4”: line 3 before, line 4 after the AI, in the pretty-printed JSON the diff compares), and edit:<path>:<key>=<text> per line edited in place (#287: any line on the right, the AI’s or an unchanged one, as raw JSON text; it may hold several lines). review::apply rebuilds each file from the run’s before/after minus those rows, with the edited lines in place of theirs (commas mended, the JSON must parse, else Accept is refused by lesson name; an edited line is always in), refuses a file that is neither as the AI wrote it nor as the last Accept left it (edited by hand since), and the handler checks the course still loads, commits one revision (an Undo step) and stores the set on course_ai_runs.excluded and .edits. A course that no longer loads is refused in the editor’s words (problems::Course::explain). A course whose first generation stopped (failed) has no editor: stopped_redirect sends its pages to /courses, or to /courses?generate=<id> (its dialog, opened) when they come from its job.

Commands

The binary takes an optional first argument, read in main before the config or the server (owner::command, #635): reset-password <email> (prints a temporary password, sets must_change_password, ends the account’s sessions), disable-2fa <email>, make-owner <email> (sets superuser = 1) and help. They open SKRONGLE_DB like the server does (migrations included) and change it directly, so they work while the server runs (SQLite WAL, busy_timeout) or with it stopped, and send nothing; a SKRONGLE_DB with no file there is refused rather than created. An unknown argument prints the list and exits 2; no argument starts the server. Built from the repo the binary is spanish-learning; a release names it skrongle (docs/self-hosting.md, “If you lock yourself out”).

Configuration

Environment variables, all optional on a mini (a hosted server needs the providers, below). Every name starts SKRONGLE_. For one release crate::env also reads the older LEARN_<name> and SPANISH_<name> (#584): the SKRONGLE_ one wins when both are set, and startup logs one warning naming each old variable and its new name.

Skrongle mini (#632, #641) is the self-hosted release: the same binary with the defaults deploy/skrongle.service sets, marked mini in the table below: SKRONGLE_SIGNUP=invite, SKRONGLE_PLANS=off and SKRONGLE_TERMS=/etc/skrongle/terms.md, plus no mail or text providers (#633). crate::is_mini() (in main.rs) is true when SKRONGLE_PLANS=off; the startup line and /healthz (ok skrongle mini <version>) then say so. #578’s SKRONGLE_SELF_HOSTED_MODE replaces that check once it lands. Self-hosting the full build is not supported: outside mini and dev mode the server refuses to start until e-mail (SKRONGLE_MAIL_PROVIDERS), texts (SKRONGLE_SMS_PROVIDERS), Google (SKRONGLE_GOOGLE_CLIENT_ID) and Stripe (SKRONGLE_STRIPE_SECRET_KEY) are all set, and the data exports bucket (SKRONGLE_S3_BUCKET) with them (require_providers in main.rs). Skrongle mini refuses to start with SKRONGLE_S3_BUCKET set. It also refuses to start without SKRONGLE_SECURE_COOKIES=1 (the hosted site is served over HTTPS) and with SKRONGLE_PROXY_AUTH_* set (proxy sign-in is a mini feature; #980). The dev server’s mailbox, phone, Google and Stripe stand in for them. The release tarballs are skrongle-<version>-<arch>.tar.gz (routes::docs::RELEASE_TARBALL, scripts/package-release.sh); each carries terms.example.md for SKRONGLE_TERMS. The pitch and the security posture are in docs/self-hosting.md, “Skrongle mini”.

VariableDefaultMeaning
SKRONGLE_BIND0.0.0.0:8080Listen address; a bare port (9000) means every interface. A value that doesn’t parse stops the server
SKRONGLE_WAITLIST_LOGINunset1: while the waitlist is on (#934, src/waitlist.rs), the waitlist page and the header show Log in (for the soft launch). Unset, nobody sees a Log in link while the waitlist is on; /login still works. Hosted only
SKRONGLE_SIGNUPopen (mini: invite)Who may make an account (src/signup.rs, #634): open (anyone), invite (only through a link a superuser makes on /superuser) or closed. Under invite and closed the first account on a server with none is made only through the owner link the server logs at startup (a new one each start), and is made a superuser. Read as is, with no older spelling; any other value stops startup. deploy/skrongle.service sets invite, deploy/aws nothing
SKRONGLE_PROXY_AUTH_HEADER, SKRONGLE_PROXY_AUTH_FROMunsetSign-in by a reverse proxy (src/proxy_auth.rs, #642): the header holding the person’s e-mail address, and the proxy’s addresses (IPs or CIDRs, comma-separated) it is trusted from, judged by the TCP peer, never X-Forwarded-For. Both or neither (one alone stops startup). A trusted request with the header is signed in without password, two-factor or rate limit; an unknown address follows SKRONGLE_SIGNUP (open makes a password-less account with its address confirmed). /login and /register only say the proxy signs in. Read as is, no older spelling. Skrongle mini only: the hosted server refuses to start with either set
SKRONGLE_DATA_DIRdataDirectory containing courses/ and themes/
SKRONGLE_DBlearn.dbSQLite file (WAL + SHM files are created next to it)
SKRONGLE_COURSES_PER_USER50Courses one account may make (no limit in dev mode unless set)
SKRONGLE_COURSE_BYTES8388608Total size of one user-made course’s files
SKRONGLE_COURSE_BYTES_PER_USER33554432Total size of the files of one account’s courses
SKRONGLE_THEMES_ENABLEDunset1: offer and wear themes. Unset (or anything else), theming is off (#1138): data/themes/ is not loaded, no course wears a theme (a stored choice is kept for when they come back), the course settings and the Start wizard have no Theme row or step, the waitlist page has no themes section, the course editor leaves out theme material, and no character art is drawn (#1153): the account badge is the gear, the course settings and the Start wizard have no picture or background, conversation lines show only the speaker’s name, and the verb drill’s person slots keep the course’s own pictures. Off until the themes have art people made
SKRONGLE_THEMES_PER_USER5Themes one learner may keep
SKRONGLE_THEME_BYTES524288Total size of the files of one learner’s themes
SKRONGLE_RETAIN_HISTORY_DAYS730Delete answers in quiz_history, finished rounds in game_activity and the daily goal’s activity_log older than this many days (at startup and hourly). 0: keep them while the account exists. A placeholder until the retention periods are decided (TODO.md)
SKRONGLE_KEEP_NOTES01: keep the notes of an AI course once it is ready (revisions then see them). Default: emptied within the hour
SKRONGLE_FAILED_COURSE_RETENTION_DAYS30Delete an AI course whose generation failed (with its notes and files) once it has gone this many days without a resume or edit (at startup and hourly, privacy::apply_retention). 0: keep them
SKRONGLE_MIN_AGE14The age the sign-up forms’ age checkbox names (#976)
SKRONGLE_LOGinfoerror/warn/info/debug/trace
SKRONGLE_SECURE_COOKIES0Set 1 behind a TLS proxy to add Secure to cookies. Required outside mini and dev mode: the hosted server refuses to start without it
SKRONGLE_BASE_PATH(empty)URL prefix when mounted behind a reverse proxy, e.g. /spanish
SKRONGLE_PUBLIC_URL—Public URL, for the Google redirect URI and the links in e-mail (unset: links use the request’s host, X-Forwarded-Host behind a proxy)
SKRONGLE_GOOGLE_CLIENT_ID / _SECRET—Enable “Continue with Google”
SKRONGLE_GOOGLE_AUTH_URL / _TOKEN_URLGoogle’sWhere the real client sends the browser and exchanges the code; for pointing at a stand-in server. Never set in production
SKRONGLE_GOOGLE_MOCK1Dev mode only, without a real client: 0 turns off the stand-in Google (/dev/google/*) that otherwise makes “Continue with Google” work locally; see testing
SKRONGLE_PLANSon (mini: off)off: no plans or payments (a self-hosted server; deploy/skrongle.service sets it). Every account counts as Plus (billing::plans_on, the one switch every gate asks), the profile shows no Subscription section or plans modal, /billing/* answers 404 (Stripe settings are ignored), and SKRONGLE_AI_KEY is shared by every account with no monthly allowance: only SKRONGLE_AI_DAILY_CAP and SKRONGLE_AI_RATE_PER_MINUTE guard it. Anything but on or off refuses to start. No older spelling
SKRONGLE_STRIPE_SECRET_KEY / _WEBHOOK_SECRET / _PRICE_ID—Enable paid subscriptions (all three plus SKRONGLE_PUBLIC_URL); see Subscriptions
TYPESAFE_API_KEY—The Jev key for the AI content check (#1036); the hosted server refuses to start without it, unless SKRONGLE_CONTENT_CHECK=external (then the worker needs it). Not in Skrongle mini. TYPESAFE_BASE_URL points it at another server. In dev mode the check calls the stand-in at /dev/ai/jev unless SKRONGLE_JEV_MODE=live and the key are set
SKRONGLE_CONTENT_CHECKinternalWhere the AI content check runs (#1041): internal on the server’s jobs thread, or external: the server only queues the checks and skrongle content-check, beside it on the same database, runs them. In external mode the Jev key and Skrongle AI’s key are set for the worker, and the server doesn’t ask for TYPESAFE_API_KEY. Hosted only
SKRONGLE_CONTENT_CHECK_TZAmerica/VancouverThe time zone whose midnight starts the nightly content check (#1038): a zoneinfo name (read from /usr/share/zoneinfo) or a POSIX TZ rule such as PST8PDT,M3.2.0,M11.1.0. Hosted only
SKRONGLE_CONTENT_CHECK_PER_NIGHT200The most courses the nightly content check looks at in one night, oldest edit first; the rest wait for the next night (#1038). AI rechecks share the cap (#1040). Hosted only
SKRONGLE_CONTENT_CHECK_RECHECK_BEHIND3How many versions behind AI_CHECK_VERSION a course’s AI check may fall before the nightly run checks it again, within the nightly cap (#1040). Hosted only
SKRONGLE_S3_BUCKET—The private S3 bucket finished data exports go to, under exports/ (#871, storage.rs); its region is AWS_REGION. Keys come from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN when set, else from the instance role (IMDSv2). Required on the hosted site (scripts/deploy-aws.sh writes it from the stack’s FilesBucket); refused by Skrongle mini; unset on the dev server, exports stay on disk
SKRONGLE_STRIPE_PRICE_ID_BASIC—The Basic plan’s recurring price, not the same as SKRONGLE_STRIPE_PRICE_ID: the webhook takes the plan from the subscription’s price, never its metadata (#963). Required on the hosted site; unset, Basic is not sold
SKRONGLE_STRIPE_PRICE_LABELMonthlyWhat a Stripe-sold subscription is said to cost; the profile’s plan chooser prices Basic and Plus itself (billing::Plan)
SKRONGLE_STRIPE_API_URLhttps://api.stripe.comWhere Stripe calls go; for pointing at a mock (dev mode without SKRONGLE_STRIPE_*: the dev payment provider)
SKRONGLE_DEV_STRIPE1Dev mode only, without real SKRONGLE_STRIPE_* settings: 0 turns off the dev payment provider (/dev/payments) that otherwise stands in for Stripe with fake keys
SKRONGLE_AI_KEY—The server’s own AI key: turns Skrongle AI on (see Skrongle AI and Pricing). Read once, removed from the environment, kept sealed in memory. Unset: every AI feature uses learners’ own keys. ./.dev-start and scripts/e2e.sh set it to the simulator’s platform key sk-ant-dev-platform
SKRONGLE_AI_PROVIDERanthropicWhat the server’s key is for: anthropic, openai or google
SKRONGLE_AI_BASE_URL(the provider’s)Where hosted calls go
SKRONGLE_AI_MODEL_TUTORclaude-haiku-5-5Skrongle AI’s model for the tutor
SKRONGLE_AI_MODEL_COURSESclaude-sonnet-5-5Skrongle AI’s model for generating, revising and adding lessons
SKRONGLE_AI_PRICESsee metering::DEFAULT_PRICESmodel=in/out[/cached],… in dollars per million tokens, added to (or replacing) the built-in table of Anthropic’s list prices; * prices unknown models (default: the dearest rate)
SKRONGLE_AI_ALLOWANCE_PLUS1.50Dollars of Skrongle AI a month on the Plus plan (CA$15 a month). Only Plus has an allowance; Free and Basic bring their own keys or buy credit packs (#931). Learners see it as a bar, never in dollars. The old name SKRONGLE_AI_ALLOWANCE_PRO is still read
SKRONGLE_AI_ALLOWANCE_FREE0Dollars of Skrongle AI a new account gets once, as credits, when its address is first confirmed (a link, a password reset or Google; #931). Not monthly, and accounts made before v87 get nothing. Spent like bought credits, on any plan
SKRONGLE_AI_DAILY_CAP10.00Skrongle AI spend across all learners per UTC day; past it hosted calls stop and the log says ALERT
SKRONGLE_AI_RATE_PER_MINUTE12Skrongle AI requests one account may start per minute
SKRONGLE_AI_CREDIT_PACKS(none)price_id:credit dollars:label,… credit packs sold through Stripe Checkout (needs Stripe), e.g. price_x:3.00:$5
SKRONGLE_LEGAL_EMAIL—Copyright / legal contact shown on /terms in place of [CONTACT EMAIL] (built-in text only); self-hosters set their own. Unset: the placeholder shows
SKRONGLE_TERMSbuiltin (mini: /etc/skrongle/terms.md)The Terms of Service text: builtin (docs/terms.md, versioned by docs/terms-changes.json; see features) or the path of the self-hoster’s own Markdown file, served as it is at /terms with the first 16 hex characters of its SHA-256 as the version, so editing it asks every account to accept again. A missing, unreadable or empty file refuses to start, naming the variable and the path. No older spelling (read directly). deploy/skrongle.service sets /etc/skrongle/terms.md
SKRONGLE_MAIL_FROM—Sender of e-mail, e.g. Skrongle <noreply@example.org>; needed with SKRONGLE_MAIL_PROVIDERS
SKRONGLE_MAIL_PER_HOUR3000 (none in dev mode)E-mails of one purpose (email-verify, login-code, course-share, …) due per window; each purpose has its own budget. Past it a message is queued for the next window with room (mail_outbox.scheduled_at), at most mail::MAX_WAIT_WINDOWS (12) ahead; mail::NO_WAIT purposes (sign-in codes) are not queued. 0: no ceiling. See self-hosting
SKRONGLE_MAIL_WINDOW3600The ceiling’s window in seconds; windows start at multiples of it (clock hours). A short one (60) lets a test watch a capped message wait and leave
SKRONGLE_MAIL_PROVIDERS—Provider names in fallback order, e.g. ses,backup; each is configured by SKRONGLE_MAIL_<NAME>_* (_KIND, and for SMTP _HOST, _PORT, _TLS, _USER, _PASS). Unset: e-mail is logged, not sent (in dev mode it goes to the dev mailbox), and the features that would send it switch off (AppState::caps, #633). See self-hosting and Without e-mail or text messages
SKRONGLE_MAIL_<NAME>_API_KEY / _API_URL— / https://api.resend.comFor _KIND=resend (Resend’s HTTP API): the API key (required) and where calls go (a mock in tests)
SKRONGLE_MAIL_<NAME>_API_KEY / _API_URL / _STREAM— / https://api.postmarkapp.com / outboundFor _KIND=postmark (Postmark’s HTTP API): the Server API token (required), where calls go (a mock in tests) and the message stream
SKRONGLE_MAIL_<NAME>_API_KEY / _API_URL— / https://api.brevo.com/v3/smtp/emailFor _KIND=brevo (Brevo’s HTTP API): the API key (required) and the endpoint (tests point it at a fake server)
SKRONGLE_SMS_PROVIDERS—Text message (SMS) provider names in fallback order, e.g. twilio,hook; each is configured by SKRONGLE_SMS_<NAME>_* (_KIND http or log, and for HTTP _URL, _FORMAT, _BODY, _AUTH/_AUTH_HEADER or _USER/_PASS, _FROM, _OPT_OUT). Unset: texts are logged, not sent (in dev mode they go to the dev phone), and the features that would send them switch off (AppState::caps, #633). See Text messages and self-hosting
SKRONGLE_SMS_FROM—Default sender number or ID for every SMS provider ({from} in a body template)
SKRONGLE_OPENAI_BASE_URLhttps://api.openai.comWhere the OpenAI provider sends requests: any OpenAI-compatible server (Ollama, LM Studio)
SKRONGLE_ANTHROPIC_BASE_URLhttps://api.anthropic.comWhere the Anthropic provider sends requests (a proxy, the dev AI provider)
SKRONGLE_GOOGLE_BASE_URLhttps://generativelanguage.googleapis.com/v1beta/openaiWhere the Google provider sends requests
SKRONGLE_AI_TIMEOUT_SECS—How long one model request may take, in place of 900 s for generation and 120 s for a chat turn (scripts/e2e.sh sets 8)
SKRONGLE_SQL_TRACE—A file: every SQL statement the server runs is appended to it as a JSON line with its time, duration, SQLite’s step and scan counters and the filled-in SQL (docs/testing.md, “Query trace”)
SKRONGLE_SOCIAL_CACHE_SECS300How long the Social motivator reuses a course’s cross-learner numbers (motivators/social.rs); 0 reads them afresh every time (scripts/e2e.sh sets 0)
SKRONGLE_AI_JOBS2AI jobs (generations, revisions, translations) that run at once, each on its own thread; the rest wait in line, at most 10 on the hosted site and 3 on Skrongle mini (#874)
SKRONGLE_AI_JOB_BUDGET_SECS3600How long one AI job (a generation, Only add, Allow overall changes) may run in all; past it the job stops before its next request, and no request runs past it
SKRONGLE_ALLOW_PRIVATE_AI_URLS01: a base URL typed into an AI form may point at loopback, private or link-local addresses (a self-hosted Ollama on the LAN), on any port. Off, such URLs are refused, and only ports 443 and 80 are allowed, so learners cannot make the server call its own network (dev mode always allows them). SKRONGLE_OPENAI_BASE_URL is never checked
SKRONGLE_SECRET_KEY(generated)64 hex characters; encrypts stored API keys. Unset: read from or created at <SKRONGLE_DB>.key (mode 0600)
SKRONGLE_NEW_SECRET_KEY01: the secret key is lost for good; create a new one, delete the saved API keys it cannot read, and turn off the authenticator apps it cannot read (two-factor off for those accounts, #1068). Without it the server refuses to start when the database has saved keys but neither SKRONGLE_SECRET_KEY nor <SKRONGLE_DB>.key is there, or the key there opens none of them
SKRONGLE_DEV_SMTP1Dev mode without SKRONGLE_MAIL_PROVIDERS: 1 sends e-mail to the dev mailbox on a free port, a number pins the port, 0 only logs it (testing)
SKRONGLE_DEV_PHONE1Dev mode without SKRONGLE_SMS_PROVIDERS: 1 sends texts to the dev phone (/dev/phone), 0 only logs them, as on a server (testing)
SKRONGLE_DEV01: dev mode (/dev/login, /dev/reset, /dev/mail, /dev/mail/send, /dev/jobs, /dev/inbox and its SMTP mailbox, /dev/sms, /dev/phone, /dev/ai (the dev AI provider, which the default base URLs point at), the stand-in Google; sent e-mail and texts keep their body; the dev mailbox needs no sign-in). Refused unless SKRONGLE_BIND is loopback, SKRONGLE_SECURE_COOKIES is not 1 and SKRONGLE_PUBLIC_URL is unset or local; the /dev/* routes also refuse proxied or non-loopback-Host requests. Set by ./.dev-start, never by deploy/
SKRONGLE_DEV_RELOAD0Dev mode only: 1 (set by ./.dev-start --watch) appends a script to every HTML page that reloads it after the server restarts; see testing
TEST_COURSE01: also offer the courses under data/test-courses/ (the test course, for automated tests). SKRONGLE_TEST_COURSE works too. Never set in production
SKRONGLE_DEV_SEED0Dev mode only: at boot, create the dev user with a course open and some progress
SKRONGLE_DEV_EXPORT_PAUSE_MSunsetDev mode only: data exports pause this long after each page and write five pages a slice, so you can watch one on /dev/jobs or stop the server halfway through (exports.rs, #875)
SKRONGLE_DEV_SEED_USERS0Dev mode only: at boot, a crowd of this many made-up learners with weeks of history (dev_crowd.rs, testing); SKRONGLE_DEV_SEED_DAYS (90) is how far back it goes
SKRONGLE_RNG_SEED—Dev mode only: a number; quizzes, sessions and games draw from it instead of the OS, so a browser test replays exactly (scripts/e2e.sh sets it from E2E_SEED)
SKRONGLE_LLM_MODE(empty)Dev mode only. Empty (or dev): the providers’ default base URLs point at the dev AI provider (/dev/ai, testing), which answers from the fixtures over their wire protocols; capture: the same, holding each request for a reply typed at the bottom of /dev/ai (Manual replies); live: the real providers (and the seed saves no keys). fixtures is read as the default
SKRONGLE_LLM_DIR./dev-llmDev mode only: where held requests are written and their replies read
SKRONGLE_LLM_FIXTURESdata/test-fixtures/llmDev mode only: the dev AI provider’s canned replies, one <purpose>.json per call site
SKRONGLE_LLM_TIMEOUT_SECS600Dev mode only: how long a held request waits for its reply
SKRONGLE_LLM_DELAY_MS0Dev mode only: how long a canned reply takes without its own delay_ms; ./.dev-start sets 500

Dev mode and the dev AI provider are described in testing.

Text messages

src/sms.rs sends text messages (SMS) the way src/mail.rs sends e-mail, and nothing outside it knows which vendor is used. Nothing sends texts yet; the first callers (a sign-in code, reminders) come with their features.

  • Sending: state.sms.send_sms(&state.db, purpose, to, text, user_id). It normalizes to to E.164 (+44 7700 900-123 → +447700900123; a number without + and its country code is refused), checks the text (not empty, at most 1600 characters), applies the limits and writes the row to sms_outbox and the sms_sends ledger. It answers the row id, SmsError::Invalid, SmsError::Country (not in SKRONGLE_SMS_COUNTRIES, sms::Policy), or SmsError::RateLimited { retry_after } (seconds). Nothing touches the network in the request.
  • Limits (sms::LIMITS and sms::Policy), counted from the sms_sends ledger so they survive restarts, hold in dev mode and outlive a deleted account: 5 an hour and 20 a day per account, 5 an hour and 10 a day per destination number (whoever sends), and SKRONGLE_SMS_MAX_PER_HOUR/_PER_DAY for the whole server. Per number and per server, sign-in codes (login-code, two-factor-confirm) and other texts are counted apart (sms::Kind), so verification texts cannot use up sign-in codes. Failed texts count too.
  • Delivery: the job runner (jobs.rs) sends due rows through the providers in SKRONGLE_SMS_PROVIDERS order, with the e-mail stack’s rules (it reuses mail::SendError, COOLDOWN and RETRY_DELAYS): a provider that could not be reached (connection error, timeout, HTTP 5xx, 408, 429) is tried last for 5 minutes; one that refused the message (any other 4xx) passes it to the next with no cooldown; a text every provider refused fails at once, one nobody took is retried after 1m, 5m, 30m, 2h and 12h, then marked failed.
  • Providers: log (the default when unconfigured outside dev mode, where the dev-phone provider keeps the texts for /dev/phone instead: logs the id, purpose, the number’s last two digits and the length, and marks it sent; not allowed in a chain) and http, which POSTs a templated body to any URL, so Twilio, Vonage, Plivo or an own webhook need configuration only. A vendor that needs more (signed requests, two calls) is a new sms::Provider impl plus a kind in sms::build_provider.
  • The http provider: _URL (https; http:// only for this machine), _FORMAT json (default) or form, _BODY a template with {to}, {from}, {text}, {id} and {purpose} filled in once and escaped for the format (JSON-escaped inside quotes, or percent-encoded). The default JSON body is {"to":"{to}","from":"{from}","text":"{text}","id":"{id}","purpose":"{purpose}"}; the default form body is Twilio’s To={to}&From={from}&Body={text}. Auth is _AUTH (a header value; _AUTH_HEADER names the header, default Authorization) or _USER + _PASS (HTTP Basic). Every request carries Idempotency-Key: sms-<outbox id>. A refusal whose body contains one of _OPT_OUT (comma-separated, default 21610, Twilio’s “attempt to send to unsubscribed recipient”) means the number opted out of texts (sms::opted_out): the outbox sets users.phone_opted_out_at for the account it was about (#969). The dev phone refuses the same way once its number replies STOP, until it replies START. A JSON template that is not JSON once filled in, a missing {to}/{text}, or a half-set credential stops startup with an error naming the variable.
  • Privacy: sent bodies are emptied (kept in dev mode, for /dev/sms), finished rows are deleted after 30 days, digit runs that look like phone numbers are scrubbed from stored and logged errors, and log lines never carry the full number. sms_outbox is in privacy::USER_TABLES; sms_sends holds only the number’s SHA-256 and is in privacy::KEPT_ON_DELETE.

Generating courses with an LLM

src/ai.rs, driven by the editor’s Generate dialog (POST /courses/{id}/generate, src/routes/courses/generate.rs). It writes into a draft (courses::draft_of: the starter course with only names changed) or resumes a stopped generation. On submit the handler empties the draft (courses::restart_generation) and marks the row generating (with no files, so Courses::get never loads or caches a half-written course: it reads load_ready_files), then starts the job on its own OS thread. The key, from the form or decrypted from the profile, is held in the job in memory and dropped when the job ends; it is never written with the course. With the server’s key (Skrongle AI) the handler first reserves the job’s estimated cost (metering::Meter::reserve); every request of the job goes through metering::Meter::call (ai::ask), which checks it on the server’s key and records it either way, and the hold is released when the job ends. A Skrongle AI call uses the same ai::Provider, client and errors as an own key; only the key and Provider::source differ (src/routes/hosted.rs decides who is offered it).

One request produces the manifest and an outline, both saved at once (course.json, courses.outline); one request per lesson produces the lesson file, validated against the manifest and everything written so far; a failed validation is sent back to the model (up to three attempts per lesson). Files are saved as they arrive. When the job ends the row becomes ready or failed (courses::finish_generation). Each job is also a jobs row of kind generate_course (src/ai/queue.rs, #874) that names the job, the course and how to rebuild the request, never the key. SKRONGLE_AI_JOBS jobs (default 2) run at once; past that a job waits in line (10 on the hosted site, 3 on Skrongle mini, then refused with a log line) and its progress reads “you’re number N”. The runner (jobs.rs) takes the next row when a slot frees and starts its generator thread (Step::Handed); one heartbeat thread renews the leases of running rows every two minutes, and the generator thread marks its row done or failed. At boot ai::at_boot puts a generation with the server’s key or a saved key back in line, and it resumes by itself; any other unfinished job (a pasted key, a revision, a translation) is failed, and fail_interrupted marks its course failed, as before. A failed run resumes from the saved outline and notes, writing only the lessons that are missing. Cancelling sets a flag the job checks before each request; so does the job’s time budget (ai::JOB_BUDGET, one hour), which also cuts each request’s timeout to what is left. ai::start checks capacity and takes the slot (or the place in line) under one lock; a slot is held by the generator thread and freed when it exits, so a job dropped from the table (its account deleted) still counts until then. The routes add a per-account quota (security::AI_JOBS_PER_USER_HOUR/_DAY), and at most ai::MAX_FINISHED_PER_USER finished jobs are kept per learner.

Each lesson request starts with a digest of the lessons already written (arrange::written_digest, #446): one line per lesson with its title, its vocabulary terms and its ## headings, and two sentences asking the model to build on them, not teach them again, and use the same words. The list is capped at arrange::DIGEST_CAP (2,000 bytes): past it the older lessons keep only their terms, then the oldest go whole. The first lesson’s request has none. After the manifest comes arrange::lesson_context: the course map (each unit from the manifest with its outline lessons in order, numbered, with summary and focus, this lesson marked) and the lesson just before and just after this one in full. A neighbour that is not written yet (the next lesson, in a fresh run) comes as its outline entry.

The system prompt is ai::DEFAULT_INSTRUCTIONS unless the learner edited it on the form; every user prompt embeds docs/course-format.md verbatim (so the documentation is the specification the model follows) and the learner’s material inside <material> tags. It also carries the rules for one kind of course (ai::SubjectKind, src/ai/prompt.rs): the outline gets the kind picked on the form, or all three while the model decides and names its choice as the outline’s kind; that kind is saved in courses.outline and every lesson, revision and added lesson gets only its rules.

Supported wire formats: the Anthropic Messages API (POST /v1/messages, default model claude-opus-5-5, with the server-side refusal fallback enabled on the models that support it) and OpenAI-compatible chat completions (POST /v1/chat/completions, any base URL, so Ollama and similar work; Google’s Gemini is reached through its OpenAI-compatible endpoint). The same client (ai::chat) serves the AI tutor with a multi-turn transcript.

Deploying

deploy/deploy.env (gitignored; template deploy/deploy.env.example documents every variable) holds the settings for both targets; a variable exported from the shell overrides the file (scripts/deploy-env.sh). scripts/deploy-all.sh runs the tests, cross-compiles once for aarch64 and calls the per-target scripts:

  • Pi / DietPi: scripts/release.sh → scripts/deploy.sh: rsync + systemd unit + nginx snippets, /etc/spanish-learning/env for the public URL and Google client. Read-only install, database in /var/lib/spanish-learning.
  • AWS: scripts/deploy-aws.sh: aws cloudformation deploy of deploy/aws/cloudformation.yaml (t4g.nano with an IAM role, static IP, security group, Caddy via first-boot user data), then the same rsync + unit pattern (deploy/aws/learnmap.service, database in /var/lib/learnmap). /etc/learnmap/env holds only the public URL and a Parameter Store path; the provider secrets (Google, Stripe, e-mail, texts) are SecureString parameters under that path (scripts/aws-env.sh), which the role may read and deploy/aws/learnmap-env.sh fetches into /run/learnmap/env (tmpfs) right before every start (#914). Caddy terminates TLS and proxies / to 127.0.0.1:8080. Backups (#964): a nightly sqlite3 .backup to a private S3 bucket the stack keeps on delete (deploy/aws/learnmap-backup.*), plus nightly EBS snapshots; the secret key is SKRONGLE_SECRET_KEY in Parameter Store, so the database alone restores (AWS setup, Backups).

User-made courses live in the database, so neither install needs any other writable path, and a release never touches the database.

Security notes

  • Passwords: argon2id with a per-user salt; unknown addresses still run a verification against a dummy hash so login timing does not reveal existence.
  • Sessions: 256-bit random tokens, 30-day TTL, deleted on logout, purged hourly.
  • CSRF: the cookie is SameSite=Lax, the JSON API only accepts Content-Type: application/json, and every POST from another site is refused with a 403 before its handler runs (security::csrf: the browser’s Sec-Fetch-Site, or else Origin against Host); requests with neither header (curl, agents) pass. Stripe’s webhook is exempt (signed).
  • Every response carries Content-Security-Policy (security::CSP: scripts and styles from the app only, frame-ancestors 'none'), X-Frame-Options: DENY, X-Content-Type-Options: nosniff and Referrer-Policy: same-origin; with SKRONGLE_SECURE_COOKIES=1 also Strict-Transport-Security. The inline scripts still need 'unsafe-inline'. In dev mode only, /login/code/device (the dev device framed by /login/code) sets frame-ancestors 'self'.
  • Rate limits (in memory, security::RateLimits, off in dev mode): 30 sign-in attempts per address per 10 minutes, 10 wrong passwords per account per 15 minutes, 20 registrations per address per hour, 10 password checks per account per 15 minutes (every profile proof of ownership, prove_owner, shares the allowance; its wrong passwords and codes count against sign-in, and a blocked sign-in blocks it); password reset: 10 link requests per address per hour, 3 links (and 3 “you already have an account” messages) per e-mail address per hour, past which nothing is sent and the page says the same, and 10 new passwords per address per 10 minutes. The address is the peer, or the last X-Forwarded-For entry when the peer is a proxy on loopback; an IPv6 address counts by its /64. Each limit keeps its own table of at most 10,000 keys; a full table drops ended windows and otherwise refuses new keys (as over the limit) rather than evicting a running one, so a flood cannot reset an account’s wrong-password count. Changing the password or two-factor sign-in, linking or unlinking Google, and Sign out everywhere else sign out the account’s other sessions; a reset link signs out all of them. Registration never says whether an address has an account (#379). Linking Google needs the password and a trip to Google that the server started for that account (oauth::StepUps); a Google-only account proves itself with its second step’s code or a fresh trip to Google.
  • Two-factor sign-in (src/two_factor.rs, features): with a method on, the password (or Google) only leaves a challenge named by a 256-bit token in the twofa cookie (HttpOnly, SameSite=Lax, 10 minutes; the table keeps its SHA-256); the session is made after the code. Texted and e-mailed codes are 6 random digits, 10 minutes, 5 tries per sign-in, kept across Send a new code (in the database, so on in dev mode too), stored as SHA-256(salt:token:code): a 6-digit code can’t be protected by hashing alone, but without the cookie’s token a copy of the database can’t be searched for it. Wrong codes (the last one too) also count as wrong passwords for the account’s limit, and while that limit is reached /login/code and its resend refuse too; Send a new code is limited to 5 per account per 15 minutes. Authenticator codes: RFC 6238, ±1 step, a step accepted once (totp_keys.last_step), compared in constant time. The TOTP secret can’t be hashed (codes are worked out from it), so it is sealed with the AI API keys’ vault (secrets::seal_totp: same master secret, its own associated data bound to the user). Turning the second step off or switching it needs the password (checked like /login: argon2 off the async workers, 10 tries per account per 15 minutes, wrong ones count against the sign-in limit); a Google-only account gives a code from its current method instead. /dev/login skips the second step.
  • Course files are validated on every save and limited to 1 MiB each and 400 per course, 8 MiB per course and 32 MiB per account in all, and 50 courses per account (courses::Limits, checked by courses::room_for in the same Db::call that writes). One account may write course files 120 times in 5 minutes (security::COURSE_WRITES_PER_USER). The cache of built user-made courses keeps at most 24 MiB of their files, least recently used out first. Lesson Markdown is rendered by pulldown-cmark; raw HTML in Markdown is escaped (shown as text) and links or images with a scheme other than http(s) or mailto point to # (content::render_markdown), so a hostile upload or an AI misled by pasted material cannot put script on a page. SVG pictures are only checked to start with <svg (or an XML prologue); they are served with Content-Security-Policy: default-src 'none' so a script inside one does not run even when the file is opened directly, and nosniff.
  • Subscriptions: the plan changes only on a Stripe webhook whose Stripe-Signature (HMAC-SHA256 of <t>.<body> under the endpoint’s secret) matches and whose timestamp is within five minutes; nothing the browser sends can make a learner premium. The Stripe secret key never leaves the server; card details never reach it (Checkout and the Portal are Stripe’s pages).
  • The server’s AI key (SKRONGLE_AI_KEY) is taken out of the process environment at boot and held only sealed (the vault, its own associated data); it is opened for one call at a time and never logged or rendered. The allowances, the daily cap and the per-account rate limit bound hosted spend, and each is checked before a call is made.
  • API keys pasted into the course generator are used for that job only and never stored. Keys saved on the profile page (src/secrets.rs) are sealed with ChaCha20-Poly1305 under a key derived (SHA-256, labelled) from the master secret, with the user id and provider as associated data, so a row cannot be read from a copy of the database alone nor moved to another account. The secret comes from SKRONGLE_SECRET_KEY or <db>.key; losing it makes stored keys unreadable (the page says so and asks for them again; the server won’t start on a database with saved keys and no key at all, or a key that opens none of them, until SKRONGLE_NEW_SECRET_KEY=1 says the old one is gone), and anyone with both files, i.e. root on the box, can read them. The page never echoes a key back: only its last four characters. Back up the .key file with the database if you want saved keys to survive a restore.
  • Account data: Profile → Your data prepares everything stored for the account in the background and e-mails a one-time link (POST /profile/export, then GET /profile/export/download?token=…; the export job in exports.rs, on the jobs runner, reads every table in privacy::USER_TABLES a page at a time, the columns in privacy::EXPORTED, into <db>.exports/) and deletes the account (POST /profile/delete: the password, or for a Google-only account the e-mail address typed out plus its second step’s code or a fresh “Confirm with Google”; refused while a Stripe subscription would keep billing). Deletion stops the account’s generation jobs, tutor chats and games in memory, removes the users row (every user table cascades from it) and the caches of its courses and themes, and clears the cookie.
  • Logs name users by id, never by name or address; model errors are scrubbed of the key before they are logged or shown (ai::redact_key).