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) injobs.rs. - App text:
i18n.rsreadsi18n/<code>.yaml(docs/i18n.md). Randomness:random.rs(OS bytes),quiz.rshas the small PRNG.
Courses and content
- Course model and validation:
content.rs(Manifest, Lesson, ContentStore),content/paradigm.rs(paradigm slots and sets,who); contract indocs/course-format.md. Placement test (placement.json, #1106):placement.rs(format, drawing, marking), routes inroutes/courses/settings.rs,templates/placement_test.html,static/placement.js. - Course registry:
courses.rs(built-in, user-made, themed copies, projects); pages inroutes/courses/(routes/courses/mod.rsroutes and shared views,routes/courses/picker.rsthe list,routes/courses/settings.rsthe settings dialog and opening,routes/courses/stat.rsthe body of a card’s stat modal (fetched bystatic/course-stat.js),routes/courses/create.rsnew, copy and translate,routes/courses/editor.rsthe editor page,routes/courses/files.rsfiles, upload and export,routes/courses/lessons.rsthe lesson table,routes/courses/generate.rsAI generation,routes/courses/revise.rsAdd 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.htmlandmasked_word.html), list indata/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 indata/moderation/controls/, notes indata/moderation/CONTROLS.md). - Themes:
themes.rs,data/themes/; course pickers instatic/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.jsdraws```abcblocks with abcjs fromstatic/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 indata/badges/. - Sprint:
sprint.rs(fill-the-gap tiles insprint/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); scriptsstatic/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) androutes/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 insecrets.rs. - Data rights:
privacy.rs(export columns, deletion),exports.rs(Download my data),storage.rsandstorage/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.rsandroutes/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.rsandcourse_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.rsandroutes/superuser/breach.rs(breach tools: sign accounts out, e-mail a notice, #1068),privacy_requests.rsandroutes/superuser/privacy.rs(privacy requests by e-mail and their log),routes/password.rs(temporary password gate). - Docs pages:
routes/docs.rs(/docssidebarSECTIONS,/llms.txt).
Dev mode only
dev.rs+routes/dev.rs(/dev/login, mailbox, stand-in Google;routes/dev/jobs_page.rsis/dev/jobs),dev_ai.rs+routes/dev_ai.rs(/dev/ai, fixtures indata/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”.
| Variable | Default | Meaning |
|---|---|---|
SKRONGLE_BIND | 0.0.0.0:8080 | Listen address; a bare port (9000) means every interface. A value that doesn’t parse stops the server |
SKRONGLE_WAITLIST_LOGIN | unset | 1: 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_SIGNUP | open (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_FROM | unset | Sign-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_DIR | data | Directory containing courses/ and themes/ |
SKRONGLE_DB | learn.db | SQLite file (WAL + SHM files are created next to it) |
SKRONGLE_COURSES_PER_USER | 50 | Courses one account may make (no limit in dev mode unless set) |
SKRONGLE_COURSE_BYTES | 8388608 | Total size of one user-made course’s files |
SKRONGLE_COURSE_BYTES_PER_USER | 33554432 | Total size of the files of one account’s courses |
SKRONGLE_THEMES_ENABLED | unset | 1: 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_USER | 5 | Themes one learner may keep |
SKRONGLE_THEME_BYTES | 524288 | Total size of the files of one learner’s themes |
SKRONGLE_RETAIN_HISTORY_DAYS | 730 | Delete 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_NOTES | 0 | 1: keep the notes of an AI course once it is ready (revisions then see them). Default: emptied within the hour |
SKRONGLE_FAILED_COURSE_RETENTION_DAYS | 30 | Delete 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_AGE | 14 | The age the sign-up forms’ age checkbox names (#976) |
SKRONGLE_LOG | info | error/warn/info/debug/trace |
SKRONGLE_SECURE_COOKIES | 0 | Set 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_URL | Google’s | Where the real client sends the browser and exchanges the code; for pointing at a stand-in server. Never set in production |
SKRONGLE_GOOGLE_MOCK | 1 | Dev 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_PLANS | on (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_CHECK | internal | Where 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_TZ | America/Vancouver | The 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_NIGHT | 200 | The 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_BEHIND | 3 | How 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_LABEL | Monthly | What a Stripe-sold subscription is said to cost; the profile’s plan chooser prices Basic and Plus itself (billing::Plan) |
SKRONGLE_STRIPE_API_URL | https://api.stripe.com | Where Stripe calls go; for pointing at a mock (dev mode without SKRONGLE_STRIPE_*: the dev payment provider) |
SKRONGLE_DEV_STRIPE | 1 | Dev 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_PROVIDER | anthropic | What the server’s key is for: anthropic, openai or google |
SKRONGLE_AI_BASE_URL | (the provider’s) | Where hosted calls go |
SKRONGLE_AI_MODEL_TUTOR | claude-haiku-5-5 | Skrongle AI’s model for the tutor |
SKRONGLE_AI_MODEL_COURSES | claude-sonnet-5-5 | Skrongle AI’s model for generating, revising and adding lessons |
SKRONGLE_AI_PRICES | see metering::DEFAULT_PRICES | model=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_PLUS | 1.50 | Dollars 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_FREE | 0 | Dollars 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_CAP | 10.00 | Skrongle AI spend across all learners per UTC day; past it hosted calls stop and the log says ALERT |
SKRONGLE_AI_RATE_PER_MINUTE | 12 | Skrongle 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_TERMS | builtin (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_HOUR | 3000 (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_WINDOW | 3600 | The 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.com | For _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 / outbound | For _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/email | For _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_URL | https://api.openai.com | Where the OpenAI provider sends requests: any OpenAI-compatible server (Ollama, LM Studio) |
SKRONGLE_ANTHROPIC_BASE_URL | https://api.anthropic.com | Where the Anthropic provider sends requests (a proxy, the dev AI provider) |
SKRONGLE_GOOGLE_BASE_URL | https://generativelanguage.googleapis.com/v1beta/openai | Where 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_SECS | 300 | How 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_JOBS | 2 | AI 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_SECS | 3600 | How 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_URLS | 0 | 1: 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_KEY | 0 | 1: 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_SMTP | 1 | Dev 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_PHONE | 1 | Dev mode without SKRONGLE_SMS_PROVIDERS: 1 sends texts to the dev phone (/dev/phone), 0 only logs them, as on a server (testing) |
SKRONGLE_DEV | 0 | 1: 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_RELOAD | 0 | Dev 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_COURSE | 0 | 1: 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_SEED | 0 | Dev mode only: at boot, create the dev user with a course open and some progress |
SKRONGLE_DEV_EXPORT_PAUSE_MS | unset | Dev 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_USERS | 0 | Dev 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-llm | Dev mode only: where held requests are written and their replies read |
SKRONGLE_LLM_FIXTURES | data/test-fixtures/llm | Dev mode only: the dev AI provider’s canned replies, one <purpose>.json per call site |
SKRONGLE_LLM_TIMEOUT_SECS | 600 | Dev mode only: how long a held request waits for its reply |
SKRONGLE_LLM_DELAY_MS | 0 | Dev 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 normalizestoto 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 tosms_outboxand thesms_sendsledger. It answers the row id,SmsError::Invalid,SmsError::Country(not inSKRONGLE_SMS_COUNTRIES,sms::Policy), orSmsError::RateLimited { retry_after }(seconds). Nothing touches the network in the request. - Limits (
sms::LIMITSandsms::Policy), counted from thesms_sendsledger 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), andSKRONGLE_SMS_MAX_PER_HOUR/_PER_DAYfor 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 inSKRONGLE_SMS_PROVIDERSorder, with the e-mail stack’s rules (it reusesmail::SendError,COOLDOWNandRETRY_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 markedfailed. - Providers:
log(the default when unconfigured outside dev mode, where thedev-phoneprovider keeps the texts for/dev/phoneinstead: logs the id, purpose, the number’s last two digits and the length, and marks it sent; not allowed in a chain) andhttp, 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 newsms::Providerimpl plus a kind insms::build_provider. - The
httpprovider:_URL(https;http://only for this machine),_FORMATjson(default) orform,_BODYa 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’sTo={to}&From={from}&Body={text}. Auth is_AUTH(a header value;_AUTH_HEADERnames the header, defaultAuthorization) or_USER+_PASS(HTTP Basic). Every request carriesIdempotency-Key: sms-<outbox id>. A refusal whose body contains one of_OPT_OUT(comma-separated, default21610, Twilio’s “attempt to send to unsubscribed recipient”) means the number opted out of texts (sms::opted_out): the outbox setsusers.phone_opted_out_atfor 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_outboxis inprivacy::USER_TABLES;sms_sendsholds only the number’s SHA-256 and is inprivacy::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/envfor the public URL and Google client. Read-only install, database in/var/lib/spanish-learning. - AWS:
scripts/deploy-aws.sh:aws cloudformation deployofdeploy/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/envholds only the public URL and a Parameter Store path; the provider secrets (Google, Stripe, e-mail, texts) areSecureStringparameters under that path (scripts/aws-env.sh), which the role may read anddeploy/aws/learnmap-env.shfetches into/run/learnmap/env(tmpfs) right before every start (#914). Caddy terminates TLS and proxies/to127.0.0.1:8080. Backups (#964): a nightlysqlite3 .backupto a private S3 bucket the stack keeps on delete (deploy/aws/learnmap-backup.*), plus nightly EBS snapshots; the secret key isSKRONGLE_SECRET_KEYin 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 acceptsContent-Type: application/json, and every POST from another site is refused with a 403 before its handler runs (security::csrf: the browser’sSec-Fetch-Site, or elseOriginagainstHost); 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: nosniffandReferrer-Policy: same-origin; withSKRONGLE_SECURE_COOKIES=1alsoStrict-Transport-Security. The inline scripts still need'unsafe-inline'. In dev mode only,/login/code/device(the dev device framed by/login/code) setsframe-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 lastX-Forwarded-Forentry 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 thetwofacookie (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/codeand 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/loginskips 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 bycourses::room_forin the sameDb::callthat 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 withContent-Security-Policy: default-src 'none'so a script inside one does not run even when the file is opened directly, andnosniff. - 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 fromSKRONGLE_SECRET_KEYor<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, untilSKRONGLE_NEW_SECRET_KEY=1says 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.keyfile 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, thenGET /profile/export/download?token=…; theexportjob inexports.rs, on thejobsrunner, reads every table inprivacy::USER_TABLESa page at a time, the columns inprivacy::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 theusersrow (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).