A security-first personal finance app built with React Native (Expo). Oathledger gives users a single, trustworthy ledger for all financial activity — receipts, accounts, budgets, and audit trails — backed by a FinTech-grade architecture.
- Stack
- Architecture Principles
- Directory Structure
- Key Conventions
- Security Model
- Data Flow
- Getting Started
| Layer | Technology | Notes |
|---|---|---|
| Mobile runtime | Expo (Managed Workflow) | SDK 51+; EAS Build for distribution |
| Language | TypeScript (strict mode) | noImplicitAny, strictNullChecks enforced |
| Styling | NativeWind v4 (Tailwind CSS) | Utility-first; design tokens via tailwind.config.ts |
| State management | Zustand | Sliced stores; immer middleware for mutations |
| Runtime validation | Zod | Schemas co-located with types; no any |
| Backend / Database | Supabase (PostgreSQL) | Row-Level Security policies on every table |
| ORM | Prisma | Schema-first; migrations via prisma migrate |
| Serverless functions | Supabase Edge Functions (Deno) | Idempotent; authenticated via Supabase JWT |
| Authentication | Supabase Auth | OTP (email/SMS) + device biometrics (expo-local-authentication) |
| ID generation | crypto.randomUUID / nanoid |
High-entropy; never sequential DB integers exposed to clients |
All TypeScript files are compiled under strict: true. The any type is banned via ESLint (@typescript-eslint/no-explicit-any). Unknown external shapes (API responses, env vars, AsyncStorage reads) are parsed through Zod schemas before entering typed application state.
external data → Zod.parse() → typed domain model → Zustand store → component props
Every boundary where data crosses a trust boundary (network, storage, push notifications, deep links) has a corresponding Zod schema. Schemas live alongside their TypeScript types in src/types/ or co-located in src/services/.
Components follow Atomic Design:
| Level | Description | Example |
|---|---|---|
atoms/ |
Single-responsibility, stateless primitives | Button, Text, Icon, Avatar |
molecules/ |
Composed atoms, still logic-free | InputField, TransactionRow, AmountDisplay |
organisms/ |
Sections with internal layout | TransactionList, AccountCard, BudgetMeter |
templates/ |
Screen skeletons with slot props | DashboardTemplate, AuthTemplate |
Presentational components receive all data through props. They own no network calls, no store subscriptions, and no side effects. Logic lives in custom hooks (src/hooks/) or services (src/services/).
See the Security Model section for full detail. Key pillars:
- High-entropy IDs — never expose sequential integers.
- PII encrypted at rest before writing to Supabase.
- All mutating Edge Functions accept an
Idempotency-Keyheader. - Row-Level Security (RLS) enforced at the database layer, not just the API layer.
Oathledger/
├── src/
│ ├── components/
│ │ ├── atoms/ # Stateless UI primitives (Button, Text, Icon…)
│ │ ├── molecules/ # Composed atoms (InputField, TransactionRow…)
│ │ ├── organisms/ # Section-level components (TransactionList…)
│ │ └── templates/ # Screen skeleton layouts with slot props
│ ├── screens/ # Route-level screen components (thin; delegate to templates + hooks)
│ ├── navigation/ # React Navigation stacks, tabs, and type-safe route params
│ ├── hooks/ # Custom React hooks (useAuth, useTransactions, useBiometric…)
│ ├── services/ # API clients, Supabase queries, Edge Function callers
│ ├── store/ # Zustand slices (authSlice, transactionSlice, uiSlice…)
│ ├── utils/ # Pure functions: formatters, validators, crypto helpers
│ ├── types/ # Shared TypeScript types, Zod schemas, and generated DB types
│ ├── constants/ # App-wide enums, route names, regex patterns, limits
│ ├── config/ # Environment variable parsing (Zod-validated at startup)
│ └── lib/ # Singleton SDK clients (supabaseClient, prismaClient stubs)
├── supabase/
│ ├── functions/ # Supabase Edge Functions (Deno, one folder per function)
│ ├── migrations/ # Timestamped SQL migration files
│ └── seed/ # Seed SQL and TypeScript seed scripts
├── prisma/
│ └── schema.prisma # Prisma data model (datasource → Supabase Postgres)
├── scripts/
│ └── scaffold.sh # Idempotent directory scaffolding script
├── assets/ # Static assets (fonts, images, icons)
├── app.json # Expo app config
├── tailwind.config.ts # NativeWind / Tailwind config
└── tsconfig.json # TypeScript compiler config (strict mode)
| Artifact | Convention | Example |
|---|---|---|
| React components | PascalCase | TransactionRow.tsx |
| Hooks | camelCase, use prefix |
useTransactions.ts |
| Services | camelCase | transactionService.ts |
| Zustand slices | camelCase, Slice suffix |
authSlice.ts |
| Types / schemas | camelCase | transaction.types.ts |
| Constants | SCREAMING_SNAKE_CASE inside file | MAX_PIN_ATTEMPTS = 5 |
| Utility functions | camelCase | formatCurrency.ts |
Every folder exposes a single index.ts re-exporting its public surface. Internal files are considered private. Imports must reference the barrel, not the internal path.
// ✅ correct
import { Button } from '@/components/atoms';
// ❌ wrong
import { Button } from '@/components/atoms/Button';Configure tsconfig.json and babel.config.js to resolve @/ → src/:
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
}
}Each domain type is paired with its Zod schema in the same file:
// src/types/transaction.types.ts
import { z } from 'zod';
export const TransactionSchema = z.object({
id: z.string().uuid(),
amount: z.number().positive(),
currency: z.string().length(3),
createdAt: z.string().datetime(),
});
export type Transaction = z.infer<typeof TransactionSchema>;All env vars are parsed at app startup via src/config/env.ts. Accessing process.env directly anywhere else is forbidden. The Zod schema serves as the single source of truth for required configuration.
- OTP Flow — Users authenticate via a time-limited one-time password delivered by email or SMS through Supabase Auth. No passwords are stored.
- Biometric Unlock — After initial OTP sign-in, subsequent sessions are gated by
expo-local-authentication(Face ID / Fingerprint). The Supabase refresh token is stored inexpo-secure-store(encrypted on-device storage backed by the Keychain/Keystore). - Session management — JWTs are short-lived (1 hour). Refresh tokens rotate on every use. Compromised sessions can be invalidated server-side via Supabase's token revocation API.
Client-generated resource IDs use crypto.randomUUID() (CSPRNG-backed, 122 bits of entropy). Sequential database auto-increment integers are never returned to or accepted from clients. Prisma models use @default(uuid()) or @default(cuid()).
Fields classified as PII (account numbers, government IDs, full names) are encrypted client-side using AES-256-GCM before transmission and storage. The encryption key is derived from the user's session and stored in expo-secure-store. Supabase only ever stores ciphertext for PII columns.
PII field → AES-256-GCM encrypt (device key) → base64 ciphertext → Supabase column
All mutating Edge Functions (POST, PUT, PATCH) require an Idempotency-Key header (UUID v4). The function stores the key with its response in a idempotency_keys table for 24 hours. Duplicate requests within the window return the cached response without re-executing side effects.
Every Supabase table has RLS enabled. Policies enforce that users can only read/write rows where user_id = auth.uid(). Application-layer authorization is a secondary defence only.
- No secrets in source code or
app.json. - Supabase service role keys are only used in Edge Functions (server-side), never in the mobile app.
- The mobile app uses the anon key plus user-scoped JWTs exclusively.
- Secrets are injected via EAS Secrets at build time.
┌──────────────┐ props/callbacks ┌──────────────────┐
│ Component │ ◄──────────────────── │ Custom Hook │
│ (Presentational) │ │ (useX.ts) │
└──────────────┘ └────────┬─────────┘
│ calls
┌────────▼─────────┐
│ Service Layer │
│ (xService.ts) │
└────────┬─────────┘
│ Supabase JS client
┌────────▼─────────┐
│ Supabase / Edge │
│ Functions │
└────────┬─────────┘
│ RLS-enforced
┌────────▼─────────┐
│ PostgreSQL (DB) │
└──────────────────┘
State mutations flow through Zustand stores, which are updated by hooks after a successful service call — not by components directly.
- Node.js 20+
- Expo CLI:
npm install -g expo-cli - Supabase CLI:
brew install supabase/tap/supabase - EAS CLI:
npm install -g eas-cli
Run the provided scaffold script from the repo root. It is idempotent and safe to re-run:
bash scripts/scaffold.shnpx create-expo-app@latest . --template blank-typescriptnpx expo install nativewind zustand zod @supabase/supabase-js \
expo-local-authentication expo-secure-store
npm install prisma --save-dev
npx prisma initsupabase init
supabase start # starts local Supabase stack via DockerCopy .env.example to .env.local and fill in your Supabase project URL and anon key.
supabase gen types typescript --local > src/types/supabase.tsnpx expo startPrivate — All rights reserved.