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 prebuild is 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 of git 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() and router.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 production reaches 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 /paywall and (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.tsnotifyListenersPurchasesInitializer in app/_layout.tsxisUnlocked. 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.ts mergeOverlay). 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.1 via POST /v1/realtime/client_secrets (GA, not beta).

6. Making a change

  1. Branch off main.
  2. Write the change and its test. For navigation, monetization, or scoring, an integration test — not a mocked unit test.
  3. npm run ci locally. Zero lint errors (warnings are tolerated); all tests green.
  4. If you touched the question bank: cd apps/mobile && npm run audit:ci.
  5. If you changed a contract, update the doc in the same PR (docs/README.md § Documentation Hygiene lists which).
  6. Open the PR. CI must be green before merge.

Before you touch these, understand them fully

AreaWhy
lib/purchases.ts, app/paywall.tsxRevenue. 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, orderQuestionsForFlowZoneThe learning engine. A regression makes paying users study the wrong material.
lib/interview/entitlement.tsFails 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 toRead
Understand the learning algorithmdocs/ALGORITHM-REPORT.md, apps/mobile/docs/architecture/ALGORITHM-AND-SCORING.md
Add or change testsdocs/TESTING.md
Deploy the API or change envdocs/DEPLOYMENT-SOP.md
Set up RevenueCat / IAPdocs/REVENUECAT_APPSTORE_SETUP.md
Submit to the App Storedocs/03-APP-STORE-SUBMISSION.md, docs/APPSTORE_LAUNCH_PLAYBOOK.md
Work on the realtime interviewdocs/realtime-interview.md
Add a native moduleapps/mobile/docs/engineering/NATIVE-MODULE-POLICY.md
See current statusdocs/MASTER-CHECKLIST.md