backend / mbprep-api
MBPrep API
The backend for a Nigerian medical-exam prep product: sign-in, a gated question bank with server-side marking, payments, notifications, an admin surface and a Telegram bot, all in one Cloudflare Worker over D1.
2026 · live · private repository
- [*]LanguagesTypeScript · SQL
- [*]FrameworksElysia · Eden Treaty · TypeBox (Elysia t) · Bun
- [*]StoresD1 · SQLite (bun:sqlite, local) · R2 · Durable Object storage (SQLite-backed)
- [*]Asynccron (*/5 and daily 07:00 UTC) · Durable Object alarms · Cloudflare Workflows (PublishBank, cross-script binding) · waitUntil background handling
- [*]InfraCloudflare Workers · Smart Placement · Workers Builds (merge to main deploys) · Workers AI · Cloudflare Email Service · service bindings
- [*]Third partiesBachs (NGN payments) · Telegram Bot API · Google OAuth / OpenID Connect · Google Drive API · Spacemail SMTP over Workers sockets · Resend (fallback mode) · Web Push (VAPID / FCM) · Cloudflare Analytics API · Hypatia (internal content engine)
apps/api is an Elysia app that runs two ways: under Bun with bun:sqlite for local work (src/index.ts) and as a Cloudflare Worker with D1, R2, a Durable Object and Workers AI in production (src/worker.ts). Both entries build the same createApp(deps); storage, mail, media and the publish launcher are injected, so tests run the real routes against SQLite. The app registers 250 routes (123 GET, 90 POST, 28 PUT, 5 DELETE, 4 PATCH): 127 under /admin, 91 under /me. Web and mobile import its App type through Eden Treaty, so a route change breaks client typechecks.
The same Worker is also @MBPrepBot. Telegram posts to bot.mbprep.app/telegram/webhook/:bot; the handler checks the secret token in constant time, answers 200 straight away and does the work in waitUntil. Each update_id is claimed once with INSERT ... ON CONFLICT DO NOTHING RETURNING. Timed exams in the bot get one ExamTimer Durable Object per paper, with an alarm at the deadline that hands the paper in.
Payments go through Bachs, a Nigerian NGN processor. The webhook is HMAC-SHA256 signed with a 300-second timestamp window and supports several live secrets during rotation. A 5-minute cron reconciles every open checkout from the last two days against Bachs, so a missed webhook never leaves a payer without access. The same cron sends reminders and notifications, purges accounts past their 14-day deletion grace and runs a watch pass that raises 14 kinds of alert. One of them, schema drift, compares the live D1 columns against a schema generated from the migrations.
Three D1 incidents in one week were each traced to a full table scan. The response was a scan guard: under bun test, every distinct statement the app runs goes through EXPLAIN QUERY PLAN once, and a scan of a table that grows with students or banks fails the run. 1,006 test cases in 53 files run under it, and they gate the deploy.
How it holds up
- Auth
- Passwordless six-digit email codes (salted SHA-256 hash, a capped number of attempts, expiry) or Google OAuth create a 30-day bearer session stored only as a salted hash. Every bank route goes through a study gate (active, unpaused membership and an opened programme), and admin routes check an ADMIN_EMAILS allowlist.
- Data
- 68 SQL migrations (123 CREATE TABLE and 83 CREATE INDEX statements); the generated expected schema has 118 tables. Users, sessions, payments, memberships, a versioned question bank (bank_versions, bank_items, bank_keys, bank_answers, bank_links, bank_media), study sessions and answers, notifications and email_log, telegram_exams and telegram_updates, admin_audit, alerts. Large drafts and pictures live in R2.
- Consistency
- Telegram updates and timed-exam hand-ins are claimed atomically with single-statement UPDATE/INSERT ... RETURNING, so the student's tap, the DO alarm and the cron sweep can race and only one wins; a stale claim expires after a timeout. Payment fulfilment is idempotent by check-then-update on payments.status plus a MAX() upsert on memberships, and webhook events are deduplicated by id. Notification sends carry a dedupe key in email_log.
- Failure
- Each cron step is wrapped so one failure (for example a missing table after a bad deploy) is logged and recorded, and the other steps and the watch pass still run. A broken check inside the watch becomes its own watch_failed alert. Mail goes through Cloudflare Email Service with SMTP as fallback, and the order is reversed for sign-in codes because Gmail deferred bursts from the new sending identity. If a DO alarm can't be armed, the paper is still handed in by the cron sweep or the student's next tap. The return page and the cron both re-query Bachs rather than trusting the webhook alone.
- Testing
- 53 *.test.ts files with 1,006 test()/it() cases (16,354 lines), run with bun test against the real routes on bun:sqlite. A preload (src/test-setup.ts) fails the whole run if any app statement fully scans a hot table, and tests also check the wrangler vars against the MCP worker's. The suite gates deploys.
Techniques
- Query-plan scan guard in the test suiteUnder bun test, every distinct statement the app issues goes through EXPLAIN QUERY PLAN once. A full scan of a hot table, or a correlated subquery that scans a growing table for each outer row, fails the run and names the statement, with a short allowlist where each exception gives its reason. It was added after three D1 cost incidents in one week, each one a query of exactly this kind.
apps/api/src/scan-guard.ts:1apps/api/src/scan-guard.ts:11apps/api/src/test-setup.ts:1 - Race-safe exam hand-in across tap, alarm and cronOne Durable Object per paper holds an alarm at the deadline. The hand-in itself is a single UPDATE ... WHERE status='open' OR a stale 'submitting' claim ... RETURNING, so the student's tap, the alarm and the 5-minute sweep can all fire and only one marks the paper. A failure releases the claim so the next attempt can try again.
apps/api/src/telegram/timer.ts:25apps/api/src/telegram/exam.ts:386apps/api/src/worker.ts:87 - Ack-first, deduplicated Telegram webhookThe webhook compares secrets in constant time, returns 200 at once and handles the update in waitUntil, so Telegram never retries on a slow database. Duplicate deliveries race on the primary key, and only the insert that RETURNING hands a row back to goes on to process the update.
apps/api/src/telegram/webhook.ts:47apps/api/src/telegram/webhook.ts:74 - Signed payment webhook plus reconciliation loopBachs deliveries are verified with HMAC-SHA256 over ts.body, within a 300-second window, and accepted against any of several v1 signatures so secrets can rotate. Events are deduplicated by id. Separately, a cron and the return page re-query Bachs for open checkouts, so access never depends on the webhook arriving. Days paid before a programme opens start counting on the opening day.
apps/api/src/services/billing.ts:218apps/api/src/services/billing.ts:403apps/api/src/services/billing.ts:389apps/api/src/services/billing.ts:176 - Schema-drift and D1 budget watchA generated EXPECTED_SCHEMA (scripts/gen-schema.ts reads the migrations) is compared each tick against sqlite_master joined to pragma_table_info, and a missing table or column raises an alert. Every D1 call logs rows_read above a threshold, and the watch also tracks the database's daily read budget through the Cloudflare Analytics API.
apps/api/src/services/watch.ts:336apps/api/src/schema.gen.ts:1apps/api/src/store.ts:32 - Isolated cron stepsThe 5-minute cron runs 13 steps, each wrapped in its own try/fallback, and finishes with the watch pass. One failing step cannot stop the others, and the watch then reports the failure.
apps/api/src/app.ts:2290apps/api/src/worker.ts:92
Counted
- 904Commits (whole monorepo)
git rev-list --count HEAD - 287Commits touching apps/api
git rev-list --count HEAD -- apps/api - 250 (123 GET, 90 POST, 28 PUT, 5 DELETE, 4 PATCH)HTTP routes
regex over apps/api/src/app.ts - 127Admin routes
routes under /admin in app.ts - 68D1 migrations
ls apps/api/migrations | grep -c .sql - 118Tables in generated schema
apps/api/src/schema.gen.ts - 83CREATE INDEX statements
grep across migrations/*.sql - 53 / 1,006Test files / cases
find -name '*.test.ts'; grep -c 'test(\|it(' - 27,661Source LOC (non-test, src)
git ls-files 'apps/api/src/*.ts' | grep -v test | xargs wc -l - 16,354Test LOC
git ls-files 'apps/api/*.test.ts' | xargs wc -l - 14Alert kinds raised by watch
ALERT_KINDS in apps/api/src/services/watch.ts - 2 (*/5 * * * *, 0 7 * * *)Cron triggers
apps/api/wrangler.jsonc