Skip to content

rdscott910/Oathledger

Repository files navigation

Oathledger

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.


Table of Contents

  1. Stack
  2. Architecture Principles
  3. Directory Structure
  4. Key Conventions
  5. Security Model
  6. Data Flow
  7. Getting Started

Stack

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

Architecture Principles

1. Strict Typing — No any

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

2. Zod for Runtime Validation

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/.

3. Modular UI — Atomic Design

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/).

4. FinTech Security

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-Key header.
  • Row-Level Security (RLS) enforced at the database layer, not just the API layer.

Directory Structure

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)

Key Conventions

File Naming

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

Barrel Exports

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';

Path Aliases

Configure tsconfig.json and babel.config.js to resolve @/src/:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  }
}

Zod Schema Co-location

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>;

Environment Variables

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.


Security Model

Identity & Authentication

  1. OTP Flow — Users authenticate via a time-limited one-time password delivered by email or SMS through Supabase Auth. No passwords are stored.
  2. Biometric Unlock — After initial OTP sign-in, subsequent sessions are gated by expo-local-authentication (Face ID / Fingerprint). The Supabase refresh token is stored in expo-secure-store (encrypted on-device storage backed by the Keychain/Keystore).
  3. 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.

ID Generation

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()).

PII Encryption at Rest

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

Idempotent API Design

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.

Row-Level Security (RLS)

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.

Secrets

  • 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.

Data Flow

┌──────────────┐    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.


Getting Started

Prerequisites

1. Scaffold the directory tree

Run the provided scaffold script from the repo root. It is idempotent and safe to re-run:

bash scripts/scaffold.sh

2. Initialize the Expo project

npx create-expo-app@latest . --template blank-typescript

3. Install core dependencies

npx expo install nativewind zustand zod @supabase/supabase-js \
  expo-local-authentication expo-secure-store
npm install prisma --save-dev
npx prisma init

4. Configure Supabase

supabase init
supabase start   # starts local Supabase stack via Docker

Copy .env.example to .env.local and fill in your Supabase project URL and anon key.

5. Generate Supabase types

supabase gen types typescript --local > src/types/supabase.ts

6. Run the app

npx expo start

License

Private — All rights reserved.

About

Personal Finance App

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors