Skip to content

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.
  1. Sign in. /learn/login sends 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/callback with 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.
  2. 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 (kind done), so it follows the reader to another browser.
  3. 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), and POST /api/learn/attempt grades them with the hub token as the reader’s identity, records a quiz-attempts row, and returns the score with what was right and the explanations. A written answer is judged by meaning by the model when ANTHROPIC_API_KEY is 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 asks POST /api/learn/start for 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).
  4. Certificate. When every level is passed the backend issues a certificates row with a code like QS53Z-LF23T, once per reader and course. /learn/cert/<code> renders it (name from the hub, course, sheikh, date, average) from GET /api/learn/verify/:code, so the same page is the verification page, and offers the PDF from GET /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 (kind certs) for the dashboard.

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.

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)
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)
  • No email adapter at the hub: no verification mail, no password reset. Add a Resend or SMTP adapter to hub/src/lib/auth.ts when 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.