Learning platform and the account hub
Three pieces, each in its own place:
| Piece | Where | What it does |
|---|---|---|
| The account hub | hub/, deployed as Hangar site kalelm-id (https://kalelm-id.mohanad.xyz) |
Accounts: register, sign in, profile. An OpenID Connect provider every site signs readers in through. A dashboard of what each site keeps for the reader. |
| Courses, tests, certificates | backend: src/learn/ (collections courses, quiz-attempts, certificates; endpoints /api/learn/*) |
Editors author a course in the admin («منصة التعلّم»): levels, each with lessons from the archive and a test. The backend grades tests and issues certificates. |
| The learn pages | frontend feature src/features/learn/ |
/learn: the reader’s courses and the catalogue. /learn/course/<slug>: levels, lessons (ticked when heard), tests. /learn/cert/<code>: a printable certificate anyone can check. Sign-in through the hub. |
How a reader moves through it
Section titled “How a reader moves through it”- Sign in.
/learn/loginsends the reader to the hub (/api/auth/oauth2/authorize, PKCE). New readers register there. The first time, the hub asks them to allow the site; then it sends them back to/learn/callbackwith a code, which the site exchanges for an access token and a refresh token. The site keeps them in one httpOnly cookie (kalelm_learn, 30 days, refreshed when the access token runs out); a marker cookie (kalelm_learn_on) tells the browser script the reader is signed in. - Study. A level’s lessons are ordinary material pages. A lesson is done when the player ends (or is within 5% of the end) or when the reader presses «أتممت الدرس». The browser keeps this in
localStorage(kalelm:learn:done) and, signed in, mirrors it to the hub through/learn/sync(kinddone), so it follows the reader to another browser. - Test. A level’s test opens once the levels before it are passed. Three kinds of question: one right option, several right options (the whole set must match), or a written answer. The page shows the questions (Payload never sends an option’s
correct, the model answer or the explanation to anyone but staff), andPOST /api/learn/attemptgrades them with the hub token as the reader’s identity, records aquiz-attemptsrow, and returns the score with what was right and the explanations. A written answer is judged by meaning by the model whenANTHROPIC_API_KEYis set, with a one-line note back to the reader; without it, by matching the model answer with tashkeel and punctuation stripped. A level can carry a time limit in minutes: the page asksPOST /api/learn/startfor a signed ticket when it opens, counts down, sends the answers itself at zero, and the backend refuses a ticket older than the limit plus thirty seconds (the attempt is recorded, not passed). - Certificate. When every level is passed the backend issues a
certificatesrow with a code likeQS53Z-LF23T, once per reader and course./learn/cert/<code>renders it (name from the hub, course, sheikh, date, average) fromGET /api/learn/verify/:code, so the same page is the verification page, and offers the PDF fromGET /api/learn/certificate/<code>.pdf. The PDF is drawn as SVG with the site’s fonts, rasterised by resvg and placed on an A4 landscape page (no browser on the host); the global «إعدادات الشهادات» sets the issuer, the signer’s name and title, a signature image and a seal from the media collection, and a footnote. The site also files the certificate at the hub (kindcerts) for the dashboard.
AI in the admin
Section titled “AI in the admin”On every level of a course two buttons read the transcripts of its lessons (POST /api/learn/ai, staff only): توليد أسئلة writes three to fifteen questions of mixed kinds with explanations and appends them to the level for the editor to keep or edit; كتابة ملخّص writes the level’s description. The course must be saved first; the page reloads with the result. Model: claude-opus-5 through the Anthropic SDK (LEARN_AI_MODEL overrides), transcripts capped at 600k characters per call. The same key turns on the grading of written answers by meaning.
The hub
Section titled “The hub”Better Auth does the accounts (email and password, sessions, lockout after ten failed logins) and, through @better-auth/oauth-provider, the OpenID Connect side: /api/auth/.well-known/openid-configuration, authorize, token, userinfo, JWKS, refresh. Every site is an OAuth client, created by an admin at /admin/sites (admins are the emails in HUB_ADMIN_EMAILS): it gets a client id and a secret shown once, which go into the site’s env. Plain-http callbacks are only accepted for a developer’s machine (the plugin registers them as native clients); production sites must call back over https.
What a site keeps for a reader is rows of (site, user, kind, key) → value in the hub’s own site_data table, written through /api/data/:site/:kind with the reader’s access token (the hub looks the token up by its hash, which validates it and names the site). The dashboard counts them per site and kind; /sites/<client id> lists them, and the reader can delete any row or everything a site holds. A kind is whatever the site says; the hub only knows title, href and when inside a value.
Pages: / (landing, or the dashboard), /login, /register, /consent, /profile (name, password), /sites/<id>, /admin/sites. Arabic and English, ?lang=.
| Where | Keys |
|---|---|
Hub (Hangar site kalelm-id) |
DATABASE_URL (its own database, kalelm_hub, on the search VPS Postgres), BETTER_AUTH_SECRET, BETTER_AUTH_URL and SITE_URL (https://kalelm-id.mohanad.xyz), HUB_ADMIN_EMAILS |
Backend (kalelm-api) |
HUB_URL — the learning endpoints verify tokens against ${HUB_URL}/api/auth/oauth2/userinfo, cached five minutes per token; ANTHROPIC_API_KEY for the AI buttons and written-answer grading (optional), LEARN_AI_MODEL (optional) |
Frontend (kalelm) |
HUB_URL, HUB_CLIENT_ID, HUB_CLIENT_SECRET, SITE_URL (the callback is ${SITE_URL}/learn/callback) |
Runbook
Section titled “Runbook”| Do | How |
|---|---|
| Stand the hub up the first time | create the database; put the env in Hangar; cd hub && npm run db:migrate with DATABASE_URL pointing at it (Better Auth’s tables, then hub/db/site_data.sql); scripts/deploy-hangar.sh hub; register with an admin email; /admin/sites → add the site with its https callback; put the id and secret in the site’s env and redeploy the site |
| Backend tables for courses | pnpm payload migrate against the production database (migration 20260912_120000_learn), same as any migration |
| Author a course | admin → منصة التعلّم → الدورات: title, sheikh, levels with lessons (from the archive) and questions (mark the right option’s number), pass mark; publish |
| Revoke a certificate | admin → الشهادات → tick «ملغاة»; the code stops verifying |
| Rotate a site’s secret | the hub has no rotate button yet: add the site again at /admin/sites, move the new id and secret into the site’s env, then delete the old row from oauthClient |
| Local dev of the whole thing | hub on 4323 (hub/.env.example), backend on 3000/3001 with HUB_URL, frontend on 4322 with the hub id and secret; the callback must be http://127.0.0.1:4322/learn/callback (localhost is refused as a redirect host) |
Not done, on purpose
Section titled “Not done, on purpose”- No email adapter at the hub: no verification mail, no password reset. Add a Resend or SMTP adapter to
hub/src/lib/auth.tswhen there is a sender. - No Google or Apple sign-in yet; Better Auth’s social providers are a config block away.
- Levels do not check that lessons were heard before the test; the tick is the reader’s own record.
- The AI writes questions from transcripts only; a lesson without a transcript contributes nothing. An editor reads what it wrote before publishing.