Nigeria's most trusted online exam prep platform. AI-powered adaptive learning for JAMB, WAEC, NECO, GCE, Post-UTME, NABTEB, and professional exams (ICAN, JUPEB, IELTS, SAT).
Built mobile-first for Nigerian networks. Naira-only pricing. WhatsApp-first communication.
Sprint 7 status (2026-05-08): Editorial factory + JAMB-fidelity CBT engine + web ingestion infrastructure shipped. Content lights up when source files land.
- Editorial factory: materials/ → DeepSeek-driven extract → classify → parse → enrich → self-audit → DB. Six pipelines (questions / syllabus / universities / course-combinations / cutoffs / reference). Self-audit with adversarial system prompt + 85/70 thresholds targets ~$0.0010 per question fully processed. CLI:
pnpm editorial-factory. See EDITORIAL_FACTORY_README.md.- CBT engine: JAMB-fidelity full-screen runner at
/cbt/[attemptId]. 9-key keyboard nav (A/B/C/D/P/N/S/R/K), JAMB-style draggable calculator, color-coded question palette, server-authoritative timer. Cheat sheet at/cbt/keyboard-help.- Web ingestion: scraping cache + robots.txt + rate limit + SSRF allow-list. Wikipedia universities wired; JAMB/WAEC/NECO/NUC/Myschool scaffolded. CLI:
pnpm web-ingest.- Topic lessons: schema + public route at
/lessons/[exam]/[subject]/[topic]with Schema.org JSON-LD.- DeepSeek cost optimisation: Redis cache for
/api/ai/explain-differently(TTL 7 days; ~$0 on cache HIT).- What's deferred: per-pipeline parser prompts (filled on first real source data), vision pipeline, mobile-CBT polish, admin queue UI. See SESSION_REPORT.md.
Sprint 6 (2026-05-06) — still applies:
- AI provider: DeepSeek-V3 / R1 primary, OpenAI gpt-4o-mini fallback. See
apps/web/lib/ai/README.md.- Moat features: AI Examiner (theory grading) + Predicted Score (data + AI hybrid).
- Pidgin: feature-flagged off via
PIDGIN_ENABLEDpending Nigerian-fluent reviewer sign-off.- Audit: AUDIT_REPORT.md — 1 Critical + 1 High + 1 Medium fixed; 1 High deferred (Next 14 → 15).
- Blog: 10 SEO-targeted articles at
/blog, sitemap-indexed.
- Tech stack
- Repository layout
- Local development setup — clone-to-running in 30 minutes
- Environment variables
- Database — schema, migrations, seed
- Deployment — Vercel, Supabase, Upstash
- Third-party integrations — Termii, Paystack, AdSense, Resend
- Operations runbook
- Architecture decisions
- Security & privacy
- Sprint 0 status
Locked decisions — do not deviate without team discussion.
| Layer | Choice | Why |
|---|---|---|
| Monorepo | pnpm workspaces + Turborepo | Fast, deterministic, well-supported on Vercel |
| Frontend | Next.js 14 App Router + TypeScript + Tailwind + shadcn/ui | Single framework for SSR + API routes |
| Backend | Next.js Route Handlers as Vercel Serverless | One deploy target, fewer cold starts than separate API |
| State | Zustand (client) + TanStack Query (server) | Minimal boilerplate, great DX |
| Database | Supabase Postgres + Realtime + Storage | Managed Postgres + auth + realtime in one |
| ORM | Drizzle | TypeScript-native, fast, no codegen step |
| Auth | Supabase Auth (phone OTP via Termii hook, email, Google) | Phone-first (Nigeria) with full identity flow |
| Cache + rate-limit | Upstash Redis | Serverless-friendly, Vercel-native |
| Background jobs | Upstash QStash | Replaces BullMQ — works on serverless |
| Scheduled jobs | Vercel Cron | Native, no extra infra |
| Payments | Paystack (NGN only) | Nigeria's default. Flutterwave as backup |
| Notifications | Termii (WhatsApp + SMS) + Resend (email) | Single Nigerian provider for WA+SMS, billed in NGN |
| Storage | Cloudflare R2 (large) + Vercel Blob (small) | R2 cheaper for video; Blob simpler for avatars |
| AI | DeepSeek-V3/R1 (primary) + OpenAI gpt-4o-mini (fallback) + optional local | Sprint 6 migrated from Anthropic for cost. See apps/web/lib/ai/README.md |
| Search | Meilisearch Cloud | Fast typo-tolerant search |
| Analytics | PostHog Cloud + Sentry | Product + error tracking |
| Ads | Google AdSense (free tier only) | Display ads for free-tier monetization |
apps/
web/ Student PWA + API routes (Vercel project #1, port 3000)
admin/ Admin dashboard (Vercel project #2, port 3001)
packages/
db/ Drizzle schema, migrations, seed
shared/ Zod schemas, TypeScript types, constants
ui/ shadcn/ui components + theme tokens
notifications/ Termii + Resend providers + template registry + QStash scheduling
config/ Shared ESLint, TypeScript, Tailwind configs
docker-compose.yml Local Postgres + Redis + Meilisearch
Target: clone-to-running in 30 minutes.
The following toolchain has been confirmed end-to-end (pnpm install, pnpm db:generate, pnpm typecheck, pnpm lint, pnpm build all green):
| Tool | Version | Notes |
|---|---|---|
| Node | 20.x or newer (tested on 25.8.2) | .nvmrc pins to 20 |
| pnpm | 9.12.0 | Pinned via packageManager field |
| Docker | 29.x (tested on 29.3.1) | For local Postgres + Redis + Meilisearch |
| Turborepo | 2.9.x | Auto-installed by pnpm |
| Next.js | 14.2.x | App Router |
| Drizzle Kit | 0.25.x | Generates migrations |
pnpm install resolves ~1050 packages and completes in ≈60 seconds on a warm cache, ≈3 minutes cold.
- Node.js 20+ (
.nvmrcpins major version) - pnpm 9+ (
corepack enable && corepack prepare pnpm@9.12.0 --activate) - Docker for local Postgres / Redis / Meilisearch
- Git with line endings set to LF (
.gitattributesenforces)
# 1. Clone
git clone https://github.com/your-org/examready-ng.git
cd examready-ng
# 2. Install dependencies (≈90s)
pnpm install
# 3. Boot local services (Postgres + Redis + Meilisearch)
docker compose up -d
# 4. Copy env file. Local dev fills in only what's needed for local DB.
cp .env.example apps/web/.env.local
# 5. Edit apps/web/.env.local — minimum required for local dev:
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/examready
# DIRECT_URL=postgresql://postgres:postgres@localhost:5432/examready
# LOCAL_DEV=true
# DEV_AUTH_BYPASS=true
# NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 (placeholder)
# NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder
# SUPABASE_SERVICE_ROLE_KEY=placeholder
# 6. Generate Drizzle migration files from schema (first run only)
pnpm db:generate
# 7. Apply migrations (auto-applies the local auth.users stub when LOCAL_DEV=true)
pnpm db:migrate
# 8. Seed sample data — 10 exams, 22 subjects, 17 topics, 50 JAMB questions
pnpm db:seed
# 9. Start dev servers (web on :3000, admin on :3001)
pnpm devVisit:
- Student app: http://localhost:3000
- Admin: http://localhost:3001
- Drizzle Studio:
pnpm db:studio→ https://local.drizzle.studio - Meilisearch UI: http://localhost:7700
Authentication flows (Supabase phone OTP via Termii) cannot be exercised locally — they require a live Supabase project + Termii account. For local frontend work:
- Set
DEV_AUTH_BYPASS=trueinapps/web/.env.local - The seed script creates a test user with id
00000000-0000-0000-0000-000000000001 - Pass
x-dev-user-id: 00000000-0000-0000-0000-000000000001on every request to authed endpoints
For real auth testing, point the env vars at a shared Supabase staging project (see Deployment).
# Wipe and restart everything
docker compose down -v
docker compose up -d
pnpm db:migrate
pnpm db:seedEvery key is documented in .env.example at the repo root. Production values live in Vercel's environment config UI — never commit secrets.
Categories:
- Runtime:
NODE_ENV,LOCAL_DEV,DEV_AUTH_BYPASS - Database:
DATABASE_URL(pooler, port 6543),DIRECT_URL(unpooled, for migrations) - Supabase:
SUPABASE_URL, anon key, service-role key, Auth Hook secret - Upstash: Redis REST URL/token, QStash token + signing keys
- Termii: API key, sender ID, WhatsApp device ID
- Resend: API key, from-email
- Paystack: secret + public keys
- AI: OpenAI + Anthropic API keys
- Analytics: PostHog + Sentry DSN
- Storage: R2 credentials, Vercel Blob token
- Cron:
CRON_SECRET—openssl rand -hex 32
22 tables organized by domain (see packages/db/src/schema/):
- Identity:
users(1:1 withauth.users),target_exams - Catalog:
exams,subjects,topics(self-referencing for subtopics) - Content:
questions(withmediajsonb andsearch_textgenerated column),options - Practice:
attempts,attempt_answers,bookmarks - Billing:
subscriptions,payments(amounts in kobo, integer) - Notifications:
notification_log - Ads:
ad_impressions(bigserial PK) - Social:
study_groups,study_group_members,ready_points_log,referrals
Three sources, applied in order by pnpm db:migrate:
migrations/local/*.sql— local dev only, creates theauth.usersstubmigrations/*.sql— Drizzle-generated, tracked in_journal.jsonmigrations/extras/*.sql— hand-written:0001_auth_link.sql— FK toauth.users+on_auth_user_createdtrigger0002_updated_at_trigger.sql— auto-bumpupdated_atcolumns0003_rls_baseline.sql— enable RLS, public-read policies for catalog tables
After every schema change in packages/db/src/schema/*.ts:
pnpm db:generate # produces a new migrations/<timestamp>_*.sql
pnpm db:migrate # applies itLocal Postgres connects as the postgres superuser, which automatically bypasses Row Level Security. RLS policies are still applied to the schema and exercised in CI integration tests, but they don't constrain day-to-day local development. In production, our API uses Supabase's service-role connection (also BYPASSRLS); RLS is enforced only on Supabase Realtime channels and direct frontend queries via the anon key.
packages/db/seed/seed.ts is idempotent. It inserts:
- 10 exams (JAMB UTME, WAEC, NECO, Post-UTME, GCE, NABTEB, ICAN, JUPEB, IELTS, SAT)
- 22 subjects (15 for JAMB, 5 for WAEC, 2 for NECO)
- 17 topics (10 JAMB Math, 7 JAMB English)
- 50 sample JAMB questions (25 Math + 25 English) drawn from 2019–2023 papers, difficulties 2–4
Bulk-import of real past papers comes through the admin CSV import tool (later sprint), not this seed.
Create two Vercel projects from this repo:
- examready-web — root directory
apps/web. Includes API routes + cron jobs. Deploys toexamready.ng. - examready-admin — root directory
apps/admin. Deploys toadmin.examready.ng.
For each project:
- Connect Git: link to your GitHub repo, set production branch to
main. - Environment variables: paste from
.env.example(production values only). MarkSUPABASE_SERVICE_ROLE_KEY,PAYSTACK_SECRET_KEY,CRON_SECRET, etc. as encrypted. - Cron jobs (web only):
apps/web/vercel.jsondeclares 4 schedules (daily reminders, weekly summary, streak rollover, subscription check). Free tier supports 2 crons; upgrade to Pro plan for all 4. - Domains: configure via Vercel UI.
- Create a project at https://supabase.com/dashboard/projects
- Copy URL, anon key, service role key into Vercel env config
- Set
DATABASE_URLto the Transaction Pooler connection (port 6543) andDIRECT_URLto the Direct Connection (port 5432) — see Project Settings → Database → Connection string - Run migrations against the production database from a developer machine:
DATABASE_URL=$PROD_POOLER DIRECT_URL=$PROD_DIRECT pnpm db:migrate
- Auth → Providers: enable Phone (Twilio or any provider — credentials don't matter, hook overrides)
- Auth → Hooks → Send SMS Hook: enable, point at
https://examready.ng/api/webhooks/supabase/send-sms, copy the secret toSUPABASE_AUTH_HOOK_SECRET - Auth → Providers → Google: enable, paste
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETfrom Google Cloud Console
- Create a Redis database at https://console.upstash.com in EU region (close to Supabase EU). Copy REST URL + token.
- Create a QStash queue (free tier supports 500 messages/day; upgrade per scale). Copy current + next signing keys.
- Create an R2 bucket named
examready-mediafor question images, video uploads, and PDFs - Generate API token with read/write access; copy to
R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY
-
Sign up at https://accounts.termii.com
-
Verify business — provide CAC certificate and Director's NIN/passport
-
Apply for WhatsApp Business API access (Termii dashboard → WhatsApp). Approval typically takes 5–10 business days.
-
Submit WhatsApp templates for approval — required before sending. The full list of templates we use is in
packages/notifications/src/templates/registry.ts. Submit all of them in one batch:otp_code(transactional)welcome(utility — English + Pidgin variants)daily_reminder(utility)streak_alert(utility)weekly_summary(utility)payment_success(transactional)payment_failed(transactional)subscription_expiring(utility)subscription_expired(utility)exam_countdown(utility)referral_qualified(utility)mock_result(utility)admin_broadcast(marketing)
Each template's exact body is in the registry file. Once approved, paste each template's id into the matching
whatsappTemplateIdfield. -
Copy
TERMII_API_KEY,TERMII_SENDER_ID,TERMII_WHATSAPP_DEVICE_IDto env config.
- Create a business account at https://dashboard.paystack.com
- Settings → Webhooks: add
https://examready.ng/api/webhooks/paystack. Paystack signs every request with HMAC-SHA512 of the body using your secret key as the HMAC key. - Plans: create three recurring plans:
- Basic Monthly — ₦2,500 / month
- Pro Monthly — ₦5,000 / month
- Pro Annual — ₦25,000 / year
Copy each plan code into
PAYSTACK_PLAN_BASIC_MONTHLY,PAYSTACK_PLAN_PRO_MONTHLY,PAYSTACK_PLAN_PRO_ANNUAL.
- Test the webhook end-to-end with a ₦100 charge in test mode before flipping live keys. The handler is functional in Sprint 0 (
apps/web/lib/webhooks/paystack.tsandapps/web/app/api/webhooks/paystack/route.ts).
- Sign up at https://resend.com
- Verify domain
examready.ng— add DKIM, SPF, DMARC records to DNS - Generate API key, copy to
RESEND_API_KEY - Set
RESEND_FROM_EMAIL=hello@examready.ng
AdSense application + approval is the longest-lead-time integration. Apply early — typical approval takes 2-4 weeks.
Before applying:
- Privacy policy page published at
/privacy(✅ included) - Terms of service page published at
/terms(✅ included) - About page at
/about(✅ included) - Contact page at
/contactwith WhatsApp and email (✅ included) - Site has 50+ pages of original content (you'll need real questions seeded — bulk-import enough past papers first)
- Site has been live with consistent traffic for 30+ days (run a soft launch first)
- Site is mobile-friendly (✅ — designed mobile-first)
- Site loads quickly (✅ — Next.js + lazyOnload AdSense)
- No prohibited content (we have none)
- Domain ownership verified
- Copy publisher ID to
NEXT_PUBLIC_ADSENSE_CLIENT_ID(format:ca-pub-XXXXXXXXXXXXXXXX) - Create 3 ad units in AdSense:
- Dashboard sidebar (300×250 medium rectangle)
- Between practice questions (336×280 large rectangle)
- Above results explanations (336×280 large rectangle)
- Copy each slot id into the matching env var:
NEXT_PUBLIC_ADSENSE_SLOT_DASHBOARD_SIDEBARNEXT_PUBLIC_ADSENSE_SLOT_BETWEEN_QUESTIONSNEXT_PUBLIC_ADSENSE_SLOT_RESULTS_TOP
- Fill in
apps/web/public/ads.txtwith the line AdSense provides - Verify site again in AdSense dashboard
apps/web/components/ads/AdSlot.tsx enforces:
- Free-tier only — basic and pro subscribers see no ads (paid for it)
- Age 13+ — under-13 users blocked at signup; AdSlot returns null if age is missing or <13
- Non-personalized for minors — users 13–17 get
data-tag-for-under-age-of-consent="1"on every ad - CLS = 0 — width/height reserved in CSS so no layout shift when ads load
- Lazy load —
<Script strategy="lazyOnload">, never blocks page interactivity - Consent gate — NDPR/GDPR banner via
ConsentBanner.tsxbefore AdSense loads (rejection hides ads entirely) - Frequency — 1 ad per 10 questions, never mid-question
The admin dashboard (later sprint) will have a kill switch to globally disable ads if AdSense flags the account.
Server- and client-side error tracking. Wired into the API handler error
boundary via apps/web/lib/observability/sentry.ts — every unhandled
error fires captureException with PII redacted via redactPii(). Set
SENTRY_DSN (server) and NEXT_PUBLIC_SENTRY_DSN (client) to enable;
missing keys = no-op, no errors shipped anywhere.
What's filtered before send:
event.userreduced to{ id }only — email, IP, username blanked- Request headers stripped entirely (auth tokens live there)
- Request body recursively redacted by key (
phone,email, etc.) and by content (Nigerian E.164 phones and email regex match →[redacted]) - App contexts redacted; runtime + OS contexts kept (PII-free)
Dashboard: see Sentry project settings → Issues. Recommend setting up alerts for new-issue and regression notifications to a Slack channel.
Browser-side event tracking + feature flags. Wired in
apps/web/lib/observability/posthog.ts. Tracks ONLY this allowlist of
events:
signup_started,signup_completed,onboarding_completedattempt_started,attempt_submittedsubscription_purchasedad_impressionai_tutor_queryconsent_choice
autocapture and session_recording are disabled — explicit events
only, no surprise data. sanitize_properties runs every property bag
through redactPii() before it leaves the browser.
User identification uses Supabase auth.users.id (random UUID) only —
never phone, email, or name. Calling resetIdentity() on logout clears
the distinct id.
Feature flags: use useFeatureFlag('flag-name'). Defaults to false until
PostHog responds, so design components to treat false + undefined the
same.
SENTRY_DSN,NEXT_PUBLIC_SENTRY_DSNNEXT_PUBLIC_POSTHOG_KEY,NEXT_PUBLIC_POSTHOG_HOST
GET /api/health — pings DB and Redis, returns 503 if either fails. Wire your uptime monitor here.
| Task | Command |
|---|---|
| Apply DB migration | pnpm db:migrate |
| Generate migration from schema | pnpm db:generate |
| Re-seed local DB | pnpm db:seed |
| Open Drizzle Studio | pnpm db:studio |
| Lint everything | pnpm lint |
| Typecheck everything | pnpm typecheck |
| Run all tests | pnpm test |
Until the admin kill switch ships, you can disable all AdSense by removing NEXT_PUBLIC_ADSENSE_CLIENT_ID from Vercel environment variables and redeploying. The <AdSenseScript> component returns null without that var; every <AdSlot> follows.
The OTP delivery flow has multiple moving parts. Smoke test:
# 1. From frontend or curl, request OTP
curl -X POST https://staging.examready.ng/api/auth/phone/request-otp \
-H "Content-Type: application/json" \
-d '{"phone":"+2348012345678"}'
# 2. Confirm:
# a) supabase.auth.signInWithOtp succeeded (no 502)
# b) Send SMS Hook fired (Supabase dashboard → Auth → Hooks → Logs)
# c) /api/webhooks/supabase/send-sms received the call (Vercel function logs)
# d) Termii sent the message (Termii dashboard → Messages)
# e) notification_log row created with status='sent'
# 3. Verify your phone got the WhatsApp message with the 6-digit code
# 4. Submit it
curl -X POST https://staging.examready.ng/api/auth/phone/verify \
-H "Content-Type: application/json" \
-d '{"phone":"+2348012345678","code":"123456"}'If WhatsApp delivery fails, the user can tap "Send via SMS" — that calls /api/auth/phone/resend which sets a Redis flag the hook reads to force SMS.
Recorded here so future you knows why we made these calls.
Originally proposed Fastify; switched because Fastify on Vercel requires running it as a single Node lambda which loses Vercel's edge optimizations. Route Handlers run as proper serverless functions per route. Trade-off: less rich plugin ecosystem; we built our own defineRoute() composer.
Drizzle ships TypeScript-native — no codegen step, no separate process to keep in sync. Faster cold starts on Vercel (no Prisma binary). We accept slightly less mature ecosystem.
These tables will dominate row counts at scale. Bigint indexes are ~4× more compact than UUID indexes — meaningful when the table grows past 100M rows.
Money in floating point is a footgun. Store kobo (integer) everywhere, convert to display naira at the UI boundary. Column is named amount_kobo (not amount_ngn) to make the unit obvious.
The on_auth_user_created trigger guarantees a public.users row exists for every auth.users row. No race window where a JWT is valid but the profile is missing. Cascade delete from auth.users cleanly removes all user data — single-statement GDPR delete.
If WhatsApp fails synchronously (Termii returns "not on whatsapp"), we try SMS in the same request. We deliberately don't chain to email after SMS fails — multi-hop fallbacks make debugging hard and the user has already waited. If both Termii channels fail, the user is shown a message and can request resend manually.
- Phone-first identity — primary user identifier is the Nigerian phone number. Email is optional.
- No BVN/NIN — we are not a fintech. KYC is minimal.
- Age gate — users under 13 cannot register (COPPA + AdSense policy)
- Non-personalized ads for minors (13–17) —
data-tag-for-under-age-of-consent="1" - No private DMs between students — study groups are moderated, group chat only
- No real-money features — Ready Points are non-redeemable
- NDPR-aware data handling — see
/privacyfor full disclosure - Webhook signature verification is mandatory:
- Paystack: HMAC-SHA512 of raw body
- Supabase: Standard Webhooks v1 (HMAC-SHA256)
- Termii: provider signature (real verification added when production account is wired)
- Rate limits everywhere:
- Auth: 5 OTP / phone / 10min, 20 / IP / hour
- User endpoints: 120/min
- Answer endpoint: 1200/min (mock CBT pace)
- Admin: 300/min
- CSP — strict allowlist in
apps/web/vercel.json. AdSense, Paystack, Supabase, Termii, PostHog, Sentry only. - No secrets in URLs — Paystack
paystack_referenceUNIQUE makes webhook idempotent without leaking refs
✅ Foundations:
- Monorepo (pnpm + Turborepo)
- Drizzle schema (22 tables, indexes, partial indexes for hot queries)
- Migrations (generated + extras for FK / triggers / RLS baseline)
- Seed (50 JAMB questions across 17 topics)
- Zod schemas + types in
packages/shared - shadcn/ui component library in
packages/ui - Termii + Resend providers in
packages/notificationswith 13 templates registered
✅ API (29 routes):
- Auth: 5 (request-otp, verify, resend, google, logout)
- Catalog: 3 (exams, subjects, topics)
- Practice/attempts: 5 (questions/practice, attempts CRUD, submit)
- Me: 7 (me, dashboard, onboarding, notifications, bookmarks×3)
- Webhooks: 3 (Paystack functional, Supabase Send SMS Hook functional, Termii stub)
- Cron: 4 stubs (handlers land in notifications sprint)
- Admin: 1 (test notification send)
- Health: 1
✅ Frontend:
- Marketing pages (landing, pricing, about, contact, privacy, terms)
- Auth + onboarding wizard (5 steps, age 13+ gate)
- Dashboard with aggregated stats + weak-topics heatmap
- Practice runner with timer + flag + AdSlot insertion
- Results page with breakdown
- Settings → notifications
- AdSlot infrastructure (tier-aware, age-aware, CLS=0, consent-gated)
- PWA manifest, robots.txt, sitemap.xml
✅ Admin shell — login + sidebar nav + 6 stub pages.
✅ Deployment configs — vercel.json (4 cron jobs, full security headers + CSP), docker-compose.yml (Postgres + Redis + Meilisearch).
⏳ Deferred to subsequent sprints:
- AI tutor backend (chat, essay grading, study plan generator)
- Paystack subscription init flow + frontend
- Real Termii delivery receipt handling
- Live leaderboards (Supabase Realtime channel)
- Study groups
- Flashcards (SM-2 algorithm)
- Meilisearch indexing pipeline
- Sentry + PostHog wiring (env vars present, code stubs)
- Parent/guardian linked accounts (schema column present)
- Bursary application flow
- Admin question CRUD + CSV bulk import
- Admin broadcast composer
- Cron handler real implementations
- Branch from
main, namefeat/<short-name>orfix/<short-name> - Conventional commits (
feat:,fix:,chore:,docs:, etc.) — enforced by Husky + commitlint - Lint + typecheck must pass:
pnpm lint && pnpm typecheck - PR description: what changed, why, how to test
UNLICENSED — proprietary, all rights reserved.