Session, bootstrap, content & lemma progress
Stripe + beta deployment guide — step-by-step checklist for test-mode products, webhooks, dual Bunny containers, and E2E verification (open printable page).
Plans & roles
Role (personal | teacher) is the product mode.
Plan (free | solo | edu) is the billing SKU.
“Premium content” is included on every paid plan (solo and edu), not a separate plan name.
| Plan (SKU) | Role after change | Premium content | Classroom / students | Billing interval | |
|---|---|---|---|---|---|
| free | personal |
— | — | View-only teaser if former teacher data exists | — |
| solo | personal |
✓ | ✓ | — | Monthly or yearly |
| edu | teacher |
✓ | ✓ | Full roster (writable) | Monthly or yearly |
Downgrading from edu sets role → personal but keeps organization, students, and groups for read-only display.
Upgrading to edu sets role → teacher (requires an existing classroom profile from onboarding).
Stripe test checkout (Payment Links)
Configure optional Payment Link URLs in .env (Stripe Dashboard → Payment Links, test mode).
| Plan | Monthly | Yearly | Price IDs (Checkout API) |
|---|---|---|---|
| free | No payment — use Account tab simulation or register without checkout | — | |
| solo | Open monthly | Open yearly | price_1TZHHbLkLnjQX4WG9e7vukDrprice_1TZHI6LkLnjQX4WGmbaXER9o |
| edu | Open monthly | Open yearly | price_1TZHJhLkLnjQX4WG77ajJGhDprice_1TZHKaLkLnjQX4WGpiq0mqUw |
Payment links: STRIPE_PAYMENT_LINK_SOLO_MONTHLY, STRIPE_PAYMENT_LINK_SOLO_YEARLY,
STRIPE_PAYMENT_LINK_EDU_MONTHLY, STRIPE_PAYMENT_LINK_EDU_YEARLY.
Price IDs: STRIPE_PRICE_SOLO_*, STRIPE_PRICE_EDU_* (legacy STRIPE_PRICE_PERSONAL_* still read as fallback).
Stripe + beta deployment
Source: docs/STRIPE_BETA_SETUP.md
Stripe + beta deployment setup
The same Docker image runs in two Bunny Magic Containers: production talks to lernlaterne-user / lernlaterne-data LibSQL databases with live Stripe keys; beta talks to beta-user / beta-data with Stripe test-mode keys. The code is identical — everything that differs lives in env vars.
The startup guard in validateRuntimeConfig refuses to boot when these are inconsistent (e.g. production with sk_test_…, beta with beta-user URL but sk_live_…).
1. Stripe Dashboard (test mode)
In test mode create the four SKUs the app already understands (see src/billingOverview.ts):
| Product | Plan SKU | Role after signup | Intervals |
|---|---|---|---|
| Solo | solo | personal | monthly + yearly |
| Edu | edu | teacher | monthly + yearly |
For each price, copy the price id (price_…) into env:
STRIPE_PRICE_SOLO_MONTHLYSTRIPE_PRICE_SOLO_YEARLYSTRIPE_PRICE_EDU_MONTHLYSTRIPE_PRICE_EDU_YEARLY
(All four required together — the catalog refuses partial config.)
For manual buy-it-now testing during bring-up, also create Payment Links and copy their URLs into the matching STRIPE_PAYMENT_LINK_* vars; they render as test buttons on /info. Once POST /v1/me/account/checkout is wired into the website you can stop using Payment Links.
Webhook endpoint
Developers → Webhooks → Add endpoint:
- URL:
https://<beta-api-host>/webhooks/stripe - Events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paid- Copy signing secret →
STRIPE_WEBHOOK_SECRETon the beta container only.
Do the same later in live mode with a separate URL pointing at the production container, and a separate signing secret.
2. Bunny Magic Containers
Provision a second container (or separate app) for beta. The two only differ in env:
| Variable | Production | Beta |
|---|---|---|
DEPLOY_ENV | production | beta |
BUNNY_USER_DATABASE_URL | lernlaterne-user URL | beta-user URL |
BUNNY_USER_DATABASE_AUTH_TOKEN | live token | beta token |
BUNNY_DATA_DATABASE_URL | lernlaterne-data URL | beta-data URL |
BUNNY_DATA_DATABASE_AUTH_TOKEN | live token | beta token |
STRIPE_SECRET_KEY | sk_live_… | sk_test_… |
STRIPE_PUBLISHABLE_KEY | pk_live_… | pk_test_… |
STRIPE_WEBHOOK_SECRET | prod endpoint secret | beta endpoint secret |
STRIPE_PRICE_* | live price ids | test price ids |
STRIPE_PAYMENT_LINK_* | live links (optional) | test links |
CORS_ORIGINS | https://www.lernlaterne.de | https://beta.lernlaterne.de |
WEBSITE_ORIGIN | https://www.lernlaterne.de | https://beta.lernlaterne.de |
API_TEST_PAGE | 0 (default) | 1 (enables /info) |
INTERNAL_API_SECRET | prod secret | distinct beta secret |
MAIL_LOG_ONLY | unset | 1 recommended (don't email real users from beta) |
Set the GitHub Actions repo variables BETA_APP_ID and (optionally) BETA_CONTAINER_NAME so docker-ghcr-bunny.yml deploys the same image to both containers.
3. End-to-end test (local + beta)
Local (Stripe CLI forwards test webhooks to your dev API):
bun run dev:bunny # API on beta DBs
stripe listen --forward-to localhost:8003/webhooks/stripe
# (set STRIPE_WEBHOOK_SECRET to the value `stripe listen` prints)
# In another shell, simulate a checkout:
stripe trigger checkout.session.completed
Beta:
- Open Stripe test-mode Payment Link from
/infoon the beta API host - Pay with
4242 4242 4242 4242 - Confirm
POST /webhooks/stripegot 200 in Stripe Dashboard → Webhook
delivery log
GET /v1/admin/stripe-eventson the beta API (with admin auth) should
show the event with status: "ok" and a message like minted invitation INV_… for solo/1m
- Open the signup link in the email (or the URL printed in beta logs) and
complete signup
GET /v1/me/accountshould showsubscription.plan: "solo",
subscription.stripeCustomerId: "cus_…"
Renewals & cancellations: use Stripe test clocks (or the Dashboard's "Cancel" button on the subscription) to drive customer.subscription.updated and .deleted. The webhook calls applySubscriptionTransition which both flips role/plan on the user and clears subscription_period_* for cancel.
4. Backups + rollback
- Backups:
seed:bunnywipes the beta DBs (refuses unless URLs contain
beta-user and beta-data). Snapshot/export beta-user + beta-data via the Bunny Dashboard before any seed run once you have meaningful test data.
- Rollback: GHCR keeps
:${{ github.sha }}tags. To revert a bad deploy
re-run BunnyWay/actions/container-update-image with a previous SHA — no rebuild required.
5. Observability
GET /healthreturns{ ok, deployEnv, stripe: { secretConfigured, webhookSecretConfigured, catalogReady } }.
Quick proof a host is on the right config.
GET /v1/admin/stripe-events?limit=50lists recent webhook events with
status (ok / ignored / error / duplicate), userId if matched, and a free-form message for diagnosis.
lernlaterne-users-api v0.1.0
{"ok":true}/auth
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/auth/login' -H 'Content-Type: application/json' -d '{"email":"you@example.com","password":"your-password"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/auth/login' -H 'Content-Type: application/json' -d '{"username":"teacher","password":"your-password"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/auth/login' -H 'Content-Type: application/json' -d '{"organizationSlug":"demo-org","username":"lena","password":"your-password"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/auth/login' -H 'Content-Type: application/json' -d '{"email":"you@example.com","password":"your-password"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/auth/login' -H 'Content-Type: application/json' -d '{"username":"teacher","password":"your-password"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/auth/login' -H 'Content-Type: application/json' -d '{"organizationSlug":"demo-org","username":"lena","password":"your-password"}'username (body, string)
organizationSlug (body, string)
*password (body, string)
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/auth/logout' -H 'Content-Type: application/json' -d '{}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/auth/logout' -H 'Content-Type: application/json' -d '{}'Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/auth/logout-all' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/auth/logout-all' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{}'/v1/admin
Examplecurl -sS -X GET 'http://beta-user.lernlaterne.de/v1/admin/snapshot?q=&limit=50' -H 'Authorization: Bearer <token>'
curl -sS -X GET 'http://beta-user.lernlaterne.de/v1/admin/snapshot?q=&limit=50' -H 'Authorization: Bearer <token>'
limit (query, number (users cap))
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/admin/invitations/account' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"targetRole":"personal","targetPlan":"solo","intervalMonths":1,"sendEmail":false}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/admin/invitations/account' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"targetRole":"personal","targetPlan":"solo","intervalMonths":1,"sendEmail":false}'*targetPlan (body, 'free'|'solo'|'edu')
presetEmail (body, string)
intervalMonths (body, 1|3|12|null)
sendEmail (body, boolean)
/v1/internal
Example—
*customerEmail (body, string)
*targetRole (body, 'personal'|'teacher')
*targetPlan (body, 'free'|'solo'|'edu')
intervalMonths (body, 1|3|12|null)
/v1/invitations
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/invitations/invite-token-placeholder/complete' -H 'Content-Type: application/json' -d '{"password":"SecurePass123"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/invitations/invite-token-placeholder/complete' -H 'Content-Type: application/json' -d '{"password":"SecurePass123"}'email (body, string)
*password (body, string)
displayName (body, string)
username (body, string)
organizationDisplayName (body, string)
organizationSlug (body, string)
/v1/me
Examplecurl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/session' -H 'Authorization: Bearer <token>'
curl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/session' -H 'Authorization: Bearer <token>'
Examplecurl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/bootstrap' -H 'Authorization: Bearer <token>'
curl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/bootstrap' -H 'Authorization: Bearer <token>'
Examplecurl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/account' -H 'Authorization: Bearer <token>'
curl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/account' -H 'Authorization: Bearer <token>'
Examplecurl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/account/subscription' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"plan":"edu","intervalMonths":12}'
curl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/account/subscription' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"plan":"edu","intervalMonths":12}'*intervalMonths (body, 1|3|12|null)
Examplecurl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/settings' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"audioVolume":80}'
curl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/settings' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"audioVolume":80}'speakLang (body, string)
hoverTranslate (body, boolean)
audioSentenceHighlight (body, boolean)
audioVolume (body, number 0–100)
audioSpeed (body, number 50–150)
videoSceneTransitions (body, boolean)
Examplecurl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/content' -H 'Authorization: Bearer <token>'
curl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/content' -H 'Authorization: Bearer <token>'
Examplecurl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/content' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"favorites":["dia001"]}'
curl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/content' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"favorites":["dia001"]}'reading (body, { upsert: …[] })
exercises (body, nested record)
/v1/me/vocabulary
Examplecurl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists' -H 'Authorization: Bearer <token>'
curl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists' -H 'Authorization: Bearer <token>'
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"title":"Week 1","entries":[{"headword":"Haus"}]}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"title":"Week 1","entries":[{"headword":"Haus"}]}'text (body, string)
entries (body, { headword | lemma | lemmaId | token, partOfSpeech? }[] (exactly one key per row besides optional POS))
Examplecurl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder' -H 'Authorization: Bearer <token>'
curl -sS -X GET 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder' -H 'Authorization: Bearer <token>'
Examplecurl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"title":"Renamed"}'
curl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"title":"Renamed"}'title (body, string)
text (body, string)
entries (body, { headword | lemma | lemmaId | token, partOfSpeech? }[])
Examplecurl -sS -X DELETE 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder' -H 'Authorization: Bearer <token>'
curl -sS -X DELETE 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder' -H 'Authorization: Bearer <token>'
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder/entries' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"entries":[{"headword":"Schule","partOfSpeech":"N"}]}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder/entries' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"entries":[{"headword":"Schule","partOfSpeech":"N"}]}'*entries (body, { headword | lemma | lemmaId | token, partOfSpeech? }[])
Examplecurl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder/entries/entry-id-placeholder' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"headword":"laufen"}'
curl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder/entries/entry-id-placeholder' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"headword":"laufen"}'*entryId (path, string)
headword (body, string)
lemma (body, string (alias of headword))
lemmaId (body, string)
token (body, string)
partOfSpeech (body, string)
Examplecurl -sS -X DELETE 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder/entries/entry-id-placeholder' -H 'Authorization: Bearer <token>'
curl -sS -X DELETE 'http://beta-user.lernlaterne.de/v1/me/vocabulary/lists/list-id-placeholder/entries/entry-id-placeholder' -H 'Authorization: Bearer <token>'
*entryId (path, string)
/v1/register
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/register/free' -H 'Content-Type: application/json' -d '{"email":"you@example.com","username":"you_global","password":"SecurePass123"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/register/free' -H 'Content-Type: application/json' -d '{"email":"you@example.com","username":"you_global","password":"SecurePass123"}'*username (body, string)
*password (body, string)
displayName (body, string)
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/register/resend-verification' -H 'Content-Type: application/json' -d '{"email":"you@example.com"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/register/resend-verification' -H 'Content-Type: application/json' -d '{"email":"you@example.com"}'Example—
*password (body, string)
organizationDisplayName (body, string)
organizationSlug (body, string)
/v1/teacher
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/teacher/students/manual' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"displayName":"New Student","password":"SecurePass123"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/teacher/students/manual' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"displayName":"New Student","password":"SecurePass123"}'*password (body, string)
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/teacher/groups' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"displayName":"Class A"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/teacher/groups' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"displayName":"Class A"}'contactEmail (body, string (email))
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/teacher/invitations/group' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"groupId":"group-id-placeholder"}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/teacher/invitations/group' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"groupId":"group-id-placeholder"}'maxUses (body, number)
expiresAt (body, number (epoch ms))
Examplecurl -sS -X POST 'http://beta-user.lernlaterne.de/v1/teacher/invitations/student' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{}'
curl -sS -X POST 'http://beta-user.lernlaterne.de/v1/teacher/invitations/student' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{}'groupId (body, string)
maxUses (body, number)
expiresAt (body, number (epoch ms))
Examplecurl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/teacher/students/student-id-placeholder' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"displayName":"Renamed"}'
curl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/teacher/students/student-id-placeholder' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"displayName":"Renamed"}'displayName (body, string)
remarks (body, string|null)
examMode (body, boolean)
Examplecurl -sS -X DELETE 'http://beta-user.lernlaterne.de/v1/teacher/students/student-id-placeholder' -H 'Authorization: Bearer <token>'
curl -sS -X DELETE 'http://beta-user.lernlaterne.de/v1/teacher/students/student-id-placeholder' -H 'Authorization: Bearer <token>'
Examplecurl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/teacher/vocabulary/lists/list-id-placeholder/share' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"shareAllStudents":true}'
curl -sS -X PATCH 'http://beta-user.lernlaterne.de/v1/teacher/vocabulary/lists/list-id-placeholder/share' -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{"shareAllStudents":true}'*shareAllStudents (body, boolean)
groupIds (body, string[])
Health
Lexicon (public, separate host)
Examplecurl -sS -X GET 'https://lexicon.lernlaterne.de/v1/de/lemmas?prefix=Ha&pageSize=10&speakLang=en'
curl -sS -X GET 'https://lexicon.lernlaterne.de/v1/de/surfaces?prefix=ha&maxSurfaces=5&maxLemmasPerSurface=5&speakLang=en'
curl -sS -X GET 'https://lexicon.lernlaterne.de/v1/de/surfaces/H%C3%A4user&speakLang=en'
curl -sS -X POST 'https://lexicon.lernlaterne.de/v1/de/lemmas/propose?speakLang=en' -H 'Content-Type: application/json' -d '{"tokens":["Haus","gehen"],"source":"user_upload"}'
curl -sS -X GET 'https://lexicon.lernlaterne.de/v1/de/lemmas?prefix=Ha&pageSize=10&speakLang=en'
curl -sS -X GET 'https://lexicon.lernlaterne.de/v1/de/surfaces?prefix=ha&maxSurfaces=5&maxLemmasPerSurface=5&speakLang=en'
curl -sS -X GET 'https://lexicon.lernlaterne.de/v1/de/surfaces/H%C3%A4user&speakLang=en'
curl -sS -X POST 'https://lexicon.lernlaterne.de/v1/de/lemmas/propose?speakLang=en' -H 'Content-Type: application/json' -d '{"tokens":["Haus","gehen"],"source":"user_upload"}'Meta
API actions
Public
DWDS helpers (test console)
/info is served (dev or API_TEST_PAGE=1), this app exposes same-origin helpers:
POST /dwds/normalize-import, POST /dwds/normalize-apkg, and
POST /dwds/extract-pdf for cleaning prose before you paste lines into vocabulary lists below.
Vocabulary rows are resolved against the Lexicon on save (not via DWDS lemmatize on this server).
Me (authenticated)
Authorization: Bearer (token stored after login).Vocabulary lists
Step 1a normalize / Anki .apkg / PDF extract (optional franc German filter) — then paste one surface or headword per line into Step 2 (optional tab + POS). Saving a list resolves lines via the Lexicon on the server. For browser autocomplete, call the public Lexicon API (see <meta name="ll-lexicon-api"> and the button below).
tesseract.js (German). Large PDFs can be slow. Stored POS: N, V, PRPN, …
Entry editing
GET {LEXICON}/v1/de/lemmas?prefix=…&pageSize=25 — public Lexicon lemma-prefix autosuggest (set via <meta name="ll-lexicon-api"> from LEXICON_API_BASE_URL) ·
POST …/lists/:id/entries append · PATCH|DELETE …/lists/:id/entries/:entryId
Teacher
Vocabulary sharing
{"shareAllStudents":true} or {"shareAllStudents":false,"groupIds":["…"]} or
{"shareAllStudents":false} to make private.
Invitations (public token URLs)
Webhooks
Suggestions call the public Lexicon API on data-lexicon-base (set from LEXICON_API_BASE_URL when this page is built). Example: GET …/v1/de/surfaces?prefix=…. The login token below is only for this users API (lists). Set USERS_LEXICON_VOCAB_MOCK=1 on the server for offline lexicon during tests.
Select a list
| Lemma | Surfaces | Actions |
|---|
JSON keys like user.displayName — edits re-render the preview.
HTML preview
Read-only snapshot of all app tables and views (file mode: one merged database; LibSQL split:
users vs data). Empty tables show column names from the schema. Uses
GET /info/api/db-overview with your session token. Password hashes and token hashes are masked.
npm scripts
Run from the lernlaterne/users repo root. Requires Bun as specified in
package.json engines.
| Script | What it does |
|---|---|
bun run dev |
Starts the API with bun --watch src/server.ts (hot reload on source changes). |
bun run start |
Production-style start: bun src/server.ts (no watch). |
bun run build |
Typecheck only: bun x tsc --noEmit. |
bun test |
Runs the Bun test suite (bun test). |
bun run seed |
Seeds demo users/org/content handles via bun scripts/seed.ts. |
bun run dev:bunny / seed:bunny / …:bunny |
Runs scripts/run-bunny.ts modes (dev, seed,
empty, delete-user, import-legacy-users,
backfill-legacy-billing, seed-fake-legacy-users) with
.beta-user-db.env and .beta-data-db.env.
|
bun run start:bunny |
Production LibSQL via .lernlaterne-user-db.env /
.lernlaterne-data-db.env. Seed refuses to wipe non-beta databases.
|
bun run set-admin-password |
Interactive helper: bun scripts/set-admin-password.ts to set an admin password. |
bun run empty-db / empty-db:bunny |
Wipes all application data (users, sessions, learner state, vocabulary). Interactive confirm
(DELETE ALL). Refuses production and non-beta LibSQL — same safety as seed wipe.
|
bun run delete-user / delete-user:bunny |
Deletes one user by --email or --id (credentials + learner/vocabulary data).
Interactive confirm; dev/beta only.
|
Legacy migration
CSV: data/legacy/export-users.csv — see data/legacy/README.md. Default is dry-run;
pass --apply to write (interactive confirm on remote DB). Bunny variants use
.beta-user-db.env / .beta-data-db.env.
| Script | What it does |
|---|---|
bun run import-legacy-users / import-legacy-users:bunny |
Imports active legacy users (deduped by email): maps SendOwl products → solo /
edu, stores legacy_api_key_hash (login with export key in
password field → forced password setup). Flags:
--include-inactive, --apply,
--stripe-enrich. Writes data/legacy/import-report.json.
|
bun run backfill-legacy-billing / backfill-legacy-billing:bunny |
For users already imported: updates user_entitlements legacy display fields
(legacy_product_name, legacy_price_cents, etc.) from the CSV without
recreating accounts. Same flags as import; optional Stripe lookup with
--stripe-enrich.
|
bun run seed-fake-legacy-users / seed-fake-legacy-users:bunny |
Creates or updates four test accounts (one per product: Lern-Paket, Online, Online 12m, Edu) with
random legacy keys. Existing emails are converted to legacy login. Keys written to
data/legacy/fake-legacy-keys.json on --apply.
|