Onboarding
Developer Onboarding
Read this first. It gets you from a clean machine to a merged PR, and it names the traps that have actually cost this project time — each one is here because it bit someone, not because it seemed plausible.
For the doc index, see docs/README.md. For mobile-specific contracts, apps/mobile/docs/README.md.
1. What you are working on
Civix is a US citizenship-test prep app. A user studies the 128-question USCIS 2025 civics bank, then practises with an AI officer that simulates the real interview.
How it makes money. Free tier: 64 of 128 questions, 3 AI explanations/day, 1 AI interview/week, English only. Premium (civix_pro monthly, civix_pro_annual annual) unlocks the rest. Purchases go through RevenueCat, which grants a single entitlement called premium.
Two things follow from that, and they govern how you should treat changes:
- The paywall and the entitlement path are revenue-critical. A bug there is not a bug report, it is lost money or a locked-out paying customer.
- Claims on the paywall are legally load-bearing. Every line in
LOCKED_ON_FREE(apps/mobile/app/paywall.tsx) must match the code that enforces it. Two false claims shipped historically — "No AI hints or explanations" (free gets 3/day) and "No adaptive flow-zone study" (free gets the full adaptive engine). Both were App Store Guideline 2.3.1 exposure. If you change a gate, change the claim in the same PR.
2. Repo layout
apps/mobile/ Expo SDK 55, React Native 0.83, Expo Router 6, Zustand, NativeWind
apps/web/ Next.js 16, React 19 — landing page + the Interview API (api.civixapp.us)
docs/ Operational + engineering docs (this file)
scripts/ci/ CI gate scripts
The root package.json delegates to each app via npm --prefix. There is no workspace linking — apps/mobile and apps/web have independent node_modules.
3. Day 1 setup
Node 20.x–22.x (apps/mobile declares >=20 <23; 23 will fail).
git clone <repo> && cd civixapp
npm run install:all # installs both apps
npm run ci # typecheck → web build → tests → lint. ~3 min. Must pass on a clean checkout.
If npm run ci fails before you have changed anything, stop and fix your environment — do not start work on top of a red baseline.
Running the web app
npm run web:dev # http://localhost:3000
Works with no env vars. The AI interview needs OPENAI_API_KEY in apps/web/.env.local; without it the API returns deterministic fallbacks rather than crashing.
Running the mobile app
npm run mobile:ios # or mobile:android
This is a bare-workflow app. ios/ and android/ are committed source, not build artifacts.
⚠️
npm run prebuildis disabled and must stay that way. It regenerates the native projects and would wipe the committed native code — five local native modules and a widget extension. There is no undo short ofgit checkout.
Offline study works with no configuration. The AI interview needs EXPO_PUBLIC_API_URL in apps/mobile/.env.
4. The traps
These are the ones that produce hours of confusion. Read them before you write code, not after.
jest.setup.ts mocks expo-router for the entire mobile suite
jest.mock('expo-router', () => ({ router: { push: jest.fn(), back: jest.fn(), replace: jest.fn() } }));
Any test asserting that a navigation call "worked" is asserting that a jest.fn() was called. It proves nothing about whether the user went anywhere.
This is not hypothetical: four consecutive fixes to the paywall close button all passed their tests and all shipped a paywall the user could not close.
For anything navigational, write an integration test instead:
import { renderRouter, screen, fireEvent, act } from 'expo-router/testing-library';
jest.unmock('expo-router');
See apps/mobile/__tests__/paywall-dismiss-integration.test.tsx and interview-exit-integration.test.tsx for the pattern. @testing-library/react-native and react-test-renderer are installed for this — react-test-renderer must match the react version exactly or the suite refuses to run.
Expo Router's navigation container is not what you think
The container holds a single __root route with the whole app nested inside it. Consequences:
navigationRef.resetRoot({ routes: [{ name: '(tabs)' }] })is silently dropped —(tabs)is not a valid route name at container level. React Navigation discards the unknown route and rehydrates__root.router.canGoBack()androuter.canDismiss()answer container-wide, not for the navigator owning the current screen. Both will cheerfully report the tab history and send you somewhere unrelated.
The shape that works is useNavigation().reset(...) from the screen itself, which targets the navigator that owns the route. See apps/mobile/lib/navigation/dismiss-paywall.ts.
Native changes require a build; JS changes do not
eas update ships JS over the air to installs whose runtimeVersion matches. eas build is only needed when native code, native deps, or config plugins change.
⚠️
eas update --channel productionreaches TestFlight and live App Store users. Treat it with the same care as a build.
Publishing OTA from a shell with bypass env vars grants everyone premium
app.config.js re-evaluates Constants extras at publish time. If EXPO_PUBLIC_TESTFLIGHT_INTERVIEW_BYPASS=true or EXPO_PUBLIC_ENABLE_TEST_BYPASS=true is set in the publishing shell, it ships into the bundle — and the interview bypass is treated as full premium for every user (store/useAppStore.ts, hasPremiumAccessFromState). Publish production updates only from a clean shell.
The API's rate limiting is not authentication
getIdentifier() (apps/web/lib/auth.ts) keys on the client-supplied x-device-id header. Rotating it voids every per-identifier limit. The HMAC signing layer exists server-side but the mobile client has no signing code, so INTERVIEW_SECURITY_MODE=enforce would 401 every real user.
Until client-side signing lands, the only bound on OpenAI spend for /chat, /evaluate, /explain, /voice is the dollar caps: OPENAI_DAILY_REQUEST_CAP, OPENAI_BUDGET_MONTHLY_USD, OPENAI_VOICE_BUDGET_MONTHLY_USD. If they are unset, enforceOpenAIBudget() returns immediately and spend is unbounded. Realtime is the exception — it has a built-in $100/day global cap.
Server entitlement verification fails closed
verifyApplicantTier() (apps/web/lib/interview/entitlement.ts) requires REVENUECAT_SECRET_API_KEY. Without it, production returns free for everyone, including real subscribers, and the realtime interview 403s. This is deliberate — the alternative is trusting a spoofable header — but it means a missing env var silently denies paying customers.
Check it with:
curl -s https://api.civixapp.us/api/health | grep entitlementVerification
5. Architecture you need in your head
Mobile
- Routing is file-based (
app/). The root layout renders a<Slot/>, so/paywalland(tabs)are siblings in one stack — that is why dismissing the paywall means resetting that stack, not the container. - State is Zustand (
store/useAppStore.ts), persisted to AsyncStorage. Anything reading persisted state must wait for hydration (useHasHydrated) — reading pre-hydration state once bounced paying users into the paywall on cold start. - Entitlement flows:
lib/purchases.ts→notifyListeners→PurchasesInitializerinapp/_layout.tsx→isUnlocked. An indeterminate result (null, from offline or SDK failure) must never downgrade a persisted subscriber. Preserve that invariant. - 8 languages. Product rule: questions and answers stay English in every language; only explanations and facts are translated (enforced in
lib/bank/index.tsmergeOverlay). Do not claim otherwise in marketing copy.
Web
- Interview state machine:
lib/interview/state-machine.ts— welcome → oath → identity → n400_review → reading → writing → civics → deliberation → complete. - Sessions are keyed by device ID; Redis in production, in-memory fallback locally.
- Realtime voice uses
gpt-realtime-2.1viaPOST /v1/realtime/client_secrets(GA, not beta).
6. Making a change
- Branch off
main. - Write the change and its test. For navigation, monetization, or scoring, an integration test — not a mocked unit test.
npm run cilocally. Zero lint errors (warnings are tolerated); all tests green.- If you touched the question bank:
cd apps/mobile && npm run audit:ci. - If you changed a contract, update the doc in the same PR (
docs/README.md§ Documentation Hygiene lists which). - Open the PR. CI must be green before merge.
Before you touch these, understand them fully
| Area | Why |
|---|---|
lib/purchases.ts, app/paywall.tsx | Revenue. A regression is lost money or a locked-out subscriber. |
lib/navigation/* | Four failed attempts live here. Integration test or it did not happen. |
lib/algorithm.ts, orderQuestionsForFlowZone | The learning engine. A regression makes paying users study the wrong material. |
lib/interview/entitlement.ts | Fails closed by design. "Fixing" it to fail open is a paywall bypass. |
ios/, android/ | Committed native source. Never regenerate. |
7. Shipping
# JS-only change, runtimeVersion unchanged:
cd apps/mobile && eas update --channel production --message "<what changed>"
# Native change, or a new App Store build:
eas build --profile production --platform ios --auto-submit
Build numbers auto-increment (appVersionSource: remote). eas submit and anything touching Apple publishing is a human action.
Full sequence and rollback: docs/PRODUCTION_LAUNCH_CHECKLIST.md, apps/mobile/docs/LAUNCH-RUNBOOK.md.
8. Where to go next
| You want to | Read |
|---|---|
| Understand the learning algorithm | docs/ALGORITHM-REPORT.md, apps/mobile/docs/architecture/ALGORITHM-AND-SCORING.md |
| Add or change tests | docs/TESTING.md |
| Deploy the API or change env | docs/DEPLOYMENT-SOP.md |
| Set up RevenueCat / IAP | docs/REVENUECAT_APPSTORE_SETUP.md |
| Submit to the App Store | docs/03-APP-STORE-SUBMISSION.md, docs/APPSTORE_LAUNCH_PLAYBOOK.md |
| Work on the realtime interview | docs/realtime-interview.md |
| Add a native module | apps/mobile/docs/engineering/NATIVE-MODULE-POLICY.md |
| See current status | docs/MASTER-CHECKLIST.md |