Skip to content

Reward-E Architecture

C4 Level 1 — System Context

C4Context
  title Reward-E — System Context

  Person(parent, "Parent", "Creates family, awards stars\nto children for chores and\ngood behaviour, manages goals.")

  Person(child, "Child", "Views their star balance,\ngoal progress, and\ntransaction history on\nparent's device.")

  System(app, "Reward-E iOS App", "Family star rewards tracker.\nParents award stars to kids;\nchildren save toward named goals.\nDual-tier: free (offline) or\npremium (cloud + family sharing).")

  System_Ext(firebase_auth, "Firebase Auth", "Sign in with Apple identity\nprovider. Anonymous-free tier\nuses a local UUID — no account.")

  System_Ext(firestore, "Cloud Firestore", "Cloud storage for premium\nfamilies. Stores families,\nchildren, transactions, goals,\nmembers, and subscription records.")

  System_Ext(firebase_functions, "Firebase Cloud Functions", "Server-side quota enforcement:\nmax 5 families/user/day,\n100 transactions/child/hour,\n20 children/family, 50 goals/child.\nNode.js 20, Functions v2.")

  System_Ext(revenuecat, "RevenueCat", "Subscription lifecycle.\nEntitlement: 'premium'.\nSupports iOS and Android.\nFamily subscription sharing\nacross all family members.")

  System_Ext(appstore, "Apple App Store\n/ Google Play", "App distribution and\nin-app purchase processing\nfor monthly and yearly\npremium subscriptions.")

  System_Ext(firebase_appcheck, "Firebase App Check", "Blocks API calls from\nnon-genuine app instances\nto prevent abuse.")

  System_Ext(cutie_e, "Cuti-E SDK", "Character-driven in-app\nfeedback and review prompts.")

  Rel(parent, app, "Awards stars, manages\nchildren and goals,\nconfigures family settings")
  Rel(child, app, "Views balance, goals,\nand transaction history")
  Rel(app, firebase_auth, "Sign in with Apple\n(premium tier only)", "Firebase SDK")
  Rel(app, firestore, "Read/write families,\nchildren, transactions, goals\n(premium tier only)", "Firebase SDK")
  Rel(firestore, firebase_functions, "Triggers on document\ncreate/write for quota\nenforcement", "Firestore triggers")
  Rel(app, revenuecat, "Purchase + verify\npremium subscription,\ncheck family entitlement", "RevenueCat SDK")
  Rel(revenuecat, appstore, "Validates receipts\nand processes purchases")
  Rel(app, firebase_appcheck, "Attests app integrity\non each Firebase call", "App Check SDK")
  Rel(app, cutie_e, "Sends feedback events", "Cuti-E SDK")

C4 Level 2 — Containers

C4Container
  title Reward-E — Containers

  Person(parent, "Parent / User", "iPhone running Reward-E")

  Container_Boundary(ios, "iOS Application") {
    Container(swiftui_views, "SwiftUI Views", "SwiftUI / iOS 16+", "HomeView — child list with star balances\nChildDetailView — transactions + goals\nSettingsView — star value, currency, family\nSubscriptionView — upgrade / manage plan\nOnboarding, invite code join flow\nSheets: AddStars, SpendStars, CreateGoal,\nAddChild, JoinFamily, MigrationProgress")

    Container(app_state, "AppState", "@MainActor ObservableObject\n@EnvironmentObject", "Central coordinator:\ncurrentUser, currentFamily, children[]\nisOffline, pendingSyncCount, syncState\nSheet state flags (showAddChild etc.)\nDeep link handling (reward-e://child/{id})\nNetworkRetry for transient Firestore errors")

    Container(storage_mgr, "StorageManager", "Singleton\nDataStorageProvider router", "Routes all data ops to active tier.\nFree: LocalStorageService (Core Data)\nPaid: CloudStorageService (Firestore)\nupgradeToPaid() / downgradeToFree()\ncheckFamilySubscription() — checks cross-\nplatform entitlement in Firestore.\nsyncWidgetData() — writes WidgetFamily\nto App Group + reloads widget timelines.")

    Container(local_storage, "LocalStorageService", "Core Data\n(free tier)", "NSManagedObjectContext on-device store.\n4 entities: CDFamily, CDChild,\nCDTransaction, CDGoal.\nInt32 with safe clamping.\nAnonymous local UUID identifier.\nNo account, no network, works offline.")

    Container(cloud_storage, "CloudStorageService", "Firestore\n(paid tier)", "Thin wrapper over FamilyService.\nAll reads/writes delegate to\nFirestore via FamilyService.")

    Container(family_svc, "FamilyService", "Singleton\nFirestore data layer", "All CRUD for families, children,\ntransactions, goals, members.\nFirestore transactions for spendStars\n(prevents race condition / negative balance).\nOptimistic locking: version + lastModifiedBy.\nInvite codes stored in invites/{code}.\nos.log + Crashlytics error reporting.")

    Container(auth_svc, "AuthService", "Singleton\nFirebase Auth", "Sign in with Apple.\nCryptographic nonce per attempt\n(SHA-256, prevents replay attacks).\nMaps Firebase user → User struct.\ndeleteAccount() removes Auth + family.")

    Container(sync_mgr, "SyncManager", "Singleton\nCombine-based", "Offline/online state machine:\nidle → offline → pendingChanges\n→ conflict → syncing\nObserves ConnectionService.$isConnected,\nPendingWritesTracker.$hasPendingWrites,\nConflictResolver.$hasConflicts.\nOn reconnect: resolves balance conflicts\nand duplicate goals, clears pending count.")

    Container(subscription_svc, "SubscriptionService", "Singleton\nRevenueCat SDK", "Products: monthly + yearly premium.\nEntitlement: 'premium'.\nOffering: 'rewarde_default'.\nlogIn(userId) ties RevenueCat to Firebase UID.\nFamily sharing: any member's active sub\ngrants premium to all family members.")

    Container(migration_svc, "MigrationService", "Singleton", "One-way Core Data → Firestore migration\nwhen free user upgrades.\nReplays all transactions and goals.\nPublishes migrationProgress: Double (0–1)\nfor UI progress indicator.")

    Container(goal_svc, "GoalService", "Singleton", "In-memory goal state per child.\nMilestone detection: 25/50/75/100%.\nLoaded at family load, cleared on sign-out.")

    Container(photo_svc, "PhotoStorageService", "Singleton\nLocal only", "Child photos stored locally only.\nNever synced to Firestore.\nResized to 400px max, JPEG 0.8.\nSaved in Documents/ChildPhotos/.")

    Container(widget_provider, "WidgetDataProvider\n(app side)", "Singleton\nApp Group writer", "Serialises WidgetFamily (name + [WidgetChild])\nto group.no.invotek.RewardE via UserDefaults.\nCalled by StorageManager after every\nstar transaction.")

    Container(cutie_svc, "CutiEService", "Cuti-E SDK", "In-context feedback at key UX moments.")
    Container(appcheck_svc, "AppCheckService", "Firebase App Check", "Attests app integrity on every Firebase call.")
    Container(connection_svc, "ConnectionService", "Singleton", "Monitors network reachability.\nPublishes $isConnected: Bool.")
  }

  Container_Boundary(widget_ext, "RewardEWidget Extension") {
    Container(widget_reader, "WidgetDataProvider\n(widget side)", "Read-only\nApp Group reader", "Reads WidgetFamily JSON from\ngroup.no.invotek.RewardE.\nDuplicated — widget cannot import main app.")

    Container(widget_provider_wk, "RewardEWidgetProvider", "WidgetKit\nTimelineProvider", "getTimeline(): loads WidgetFamily,\ncreates single entry,\n15-minute refresh fallback.\nMain app triggers immediate reload\nvia WidgetCenter after every transaction.")

    Container(widget_views, "Small / Medium / Large\nWidget Views", "SwiftUI\nWidgetKit", "Displays children star balances.\nDeep link: reward-e://child/{childId}\nto open specific child on tap.")
  }

  Container_Boundary(backend, "Firebase Backend") {
    ContainerDb(firestore_db, "Cloud Firestore", "NoSQL document store", "families/{familyId}\n  members/{userId} — role: admin/parent/viewer\n  children/{childId}\n    transactions/{txnId} — earn/spend\n    goals/{goalId}\ninvites/{inviteCode} → familyId")

    Container(cf_functions, "Cloud Functions", "Node.js 20\nFirebase Functions v2", "onFamilyCreated: max 5/user/day\nTransaction writes: max 100/child/hour\nChildren per family: max 20\nGoals per child: max 50\nRate limit state in _rateLimits/{userId}")
  }

  System_Ext(firebase_auth_ext, "Firebase Auth", "Sign in with Apple")
  System_Ext(revenuecat_ext, "RevenueCat", "Subscription API")

  Rel(parent, swiftui_views, "Awards stars,\ncreates children,\nmanages goals", "Touch / SwiftUI")
  Rel(swiftui_views, app_state, "Reads app state,\ntriggers actions", "@EnvironmentObject")
  Rel(app_state, storage_mgr, "Delegates all\ndata operations")
  Rel(storage_mgr, local_storage, "Free tier:\nall CRUD operations", "Core Data")
  Rel(storage_mgr, cloud_storage, "Paid tier:\nall CRUD operations", "Firestore")
  Rel(cloud_storage, family_svc, "Delegates to\nFamilyService")
  Rel(family_svc, firestore_db, "Reads/writes\nfamilies, children,\ntransactions, goals", "Firebase SDK")
  Rel(cf_functions, firestore_db, "Enforces quotas\nand rate limits", "Firestore triggers")
  Rel(app_state, auth_svc, "Sign in / sign out")
  Rel(auth_svc, firebase_auth_ext, "Apple OAuth\nwith nonce", "Firebase Auth SDK")
  Rel(app_state, subscription_svc, "Check + purchase\nsubscription")
  Rel(subscription_svc, revenuecat_ext, "Purchase validation\n+ entitlement check", "RevenueCat SDK")
  Rel(storage_mgr, widget_provider, "Write widget data\nafter every star\ntransaction")
  Rel(app_state, sync_mgr, "Offline sync\ncoordination")
  Rel(sync_mgr, connection_svc, "Observes\nconnectivity")
  Rel(widget_reader, widget_provider_wk, "Reads WidgetFamily\nfor timeline entry")
  Rel(widget_provider_wk, widget_views, "Renders widget")
  UpdateLayoutConfig($c4ShapeInRow="4")

C4 Level 3 — Components (iOS App)

C4Component
  title Reward-E — iOS App Components

  Container_Boundary(ios_app, "iOS Application") {

    Component(views, "Views", "SwiftUI", "HomeView — child list with star balances\nChildDetailView — transactions + goals\nSettingsView — star value, currency, family\nSubscriptionView — upgrade / manage plan")

    Component(app_state, "AppState", "@MainActor ObservableObject", "Central coordinator:\ncurrentUser, currentFamily, children[]\nisOffline, pendingSyncCount, syncState\nSheet state flags, deep link handling")

    Component(auth_svc, "AuthService", "Singleton, Firebase Auth", "Sign in with Apple.\nCryptographic nonce per attempt (SHA-256).\nMaps Firebase user → User struct.\ndeleteAccount() removes Auth + family data.")

    Component(storage_mgr, "StorageManager", "Singleton, DataStorageProvider router", "Routes all data ops to active tier.\nFree → LocalStorageService\nPaid → CloudStorageService\nupgradeToPaid() / downgradeToFree()\nsyncWidgetData() after every transaction.")

    Component(local_storage, "LocalStorageService", "Core Data (free tier)", "NSManagedObjectContext on-device store.\n4 entities: CDFamily, CDChild,\nCDTransaction, CDGoal.\nAnonymous local UUID identifier.\nNo network, fully offline.")

    Component(cloud_storage, "CloudStorageService", "Firestore (paid tier)", "Thin wrapper over FamilyService.\nAll reads/writes delegate to Firestore\nvia FamilyService singleton.")

    Component(family_svc, "FamilyService", "Singleton, Firestore CRUD", "All Firestore reads/writes for families,\nchildren, transactions, goals.\nOptimistic locking, batch writes,\ntransactions, invite code generation.")

    Component(sync_mgr, "SyncManager", "Singleton, Combine-based", "Offline/online state machine:\nidle → offline → pendingChanges\n→ conflict → syncing.\nResolves balance conflicts and\nduplicate goals on reconnect.")

    Component(subscription_svc, "SubscriptionService", "Singleton, RevenueCat SDK", "Products: monthly + yearly premium.\nEntitlement: 'premium'.\nlogIn(userId) ties RevenueCat to Firebase UID.\nFamily sharing across all members.")

    Component(goal_svc, "GoalService", "Singleton, ObservableObject", "Goal lifecycle: create, progress,\ncomplete, delete. Max 50 per child.\nTracks goal progress and completion state.")

    Component(migration_svc, "MigrationService", "Singleton, ObservableObject", "One-time Core Data → Firestore migration\non premium upgrade. Progress tracking,\nrollback on failure.")

    Component(photo_svc, "PhotoStorageService", "Singleton, FileManager", "Child profile photo storage.\nLocal file system (App Group).\nResize to 400px, JPEG compression.")

    Component(widget_provider, "WidgetDataProvider", "Singleton, App Group", "Syncs family data to shared App Group\nfor iOS widget display.\nJSON serialization of star balances.")

    Component(cutie_svc, "CutiEService", "Singleton, ObservableObject", "Cuti-E character feedback integration.\nMotivational messages for children\nbased on star activity.")

    Component(appcheck_svc, "AppCheckService", "Singleton, Firebase App Check", "Device attestation for Firebase.\nPrevents unauthorized API access\nfrom non-genuine app instances.")

    Component(connection_svc, "ConnectionService", "Singleton, ObservableObject", "Network reachability monitor.\nPublishes online/offline state\nfor SyncManager and UI indicators.")
  }

  System_Ext(firebase_auth, "Firebase Auth", "Apple Sign-In identity provider")
  System_Ext(firestore, "Cloud Firestore", "Cloud document storage")
  SystemDb_Ext(core_data, "Core Data Store", "On-device SQLite")
  System_Ext(revenuecat, "RevenueCat", "Subscription lifecycle API")

  Rel(views, app_state, "Reads state, triggers actions", "@EnvironmentObject")
  Rel(app_state, auth_svc, "Sign in / sign out / delete account")
  Rel(app_state, storage_mgr, "Delegates all data operations")
  Rel(app_state, sync_mgr, "Offline sync coordination")
  Rel(app_state, subscription_svc, "Check + purchase subscription")
  Rel(app_state, goal_svc, "Goal CRUD operations")
  Rel(app_state, cutie_svc, "Character feedback triggers")
  Rel(storage_mgr, local_storage, "Free tier: all CRUD", "Core Data")
  Rel(storage_mgr, cloud_storage, "Paid tier: all CRUD", "Firestore")
  Rel(storage_mgr, widget_provider, "Syncs data after transactions", "App Group")
  Rel(auth_svc, firebase_auth, "Apple OAuth with nonce", "Firebase Auth SDK")
  Rel(cloud_storage, family_svc, "Delegates all Firestore CRUD")
  Rel(family_svc, firestore, "Read/write families,\nchildren, transactions, goals", "Firebase SDK")
  Rel(local_storage, core_data, "Persistent local storage", "NSManagedObjectContext")
  Rel(sync_mgr, storage_mgr, "Triggers sync on reconnect")
  Rel(sync_mgr, connection_svc, "Monitors network state", "Combine")
  Rel(subscription_svc, revenuecat, "Purchase validation\n+ entitlement check", "RevenueCat SDK")
  Rel(migration_svc, local_storage, "Reads Core Data for migration")
  Rel(migration_svc, cloud_storage, "Writes migrated data to cloud")
  Rel(photo_svc, core_data, "Stores photos locally", "FileManager")
  Rel(appcheck_svc, firebase_auth, "Device attestation", "App Check SDK")

  UpdateLayoutConfig($c4ShapeInRow="4", $c4BoundaryInRow="1")

App: Reward-E — family star rewards tracker for kids
Bundle ID: no.invotek.RewardE
Platform: iOS 16+ (primary), Android (parity), Web (marketing)
Version: 1.0.5 (build 34)


Overview

Reward-E is a family app where parents award stars to children for chores and good behaviour. Each star has a configurable monetary value (e.g. 10 NOK). Children can save stars toward named goals or spend them on rewards.

The app supports two storage tiers:

Tier Auth Storage Family sharing
Free None (anonymous local UUID) Core Data on-device No
Paid (Premium) Sign in with Apple + Firebase Auth Firestore (cloud) Yes — invite codes

Upgrading triggers a one-time migration of local Core Data data to Firestore.


iOS Architecture

Pattern: MVVM + Service Layer

┌──────────────────────────────────────────────────────────────┐
│  Views (SwiftUI)                                             │
│  HomeView · ChildDetailView · SettingsView · SubscriptionView│
│  + Component sheets: AddStars, SpendStars, CreateGoal, ...   │
└───────────────────────┬──────────────────────────────────────┘
                        │ @EnvironmentObject / @ObservedObject
┌───────────────────────▼──────────────────────────────────────┐
│  AppState  (@MainActor ObservableObject)                     │
│  • currentUser, currentFamily, children                      │
│  • isOffline, pendingSyncCount, syncState                    │
│  • Sheet state flags (showAddChild, showAddStars, …)         │
│  • Deep link handling (reward-e://child/{childId})           │
└──┬────────────┬──────────────┬──────────────┬───────────────┘
   │            │              │              │
   ▼            ▼              ▼              ▼
AuthService  StorageManager  SyncManager  SubscriptionService
(Apple/FB)   (tier router)   (conflicts)  (RevenueCat)
                │
        ┌───────┴────────┐
        ▼                ▼
 LocalStorageService  CloudStorageService
 (Core Data)          (Firestore via FamilyService)

All service classes are singletons (shared), all @MainActor.


Services

AuthService

Handles Sign in with Apple + Firebase Auth.

  • Generates a cryptographically secure nonce per sign-in attempt to prevent replay attacks
  • On success, returns a User struct (id, name, email) mapped from Auth.auth().currentUser
  • deleteAccount() removes the Firebase Auth account and triggers family cleanup

StorageManager

Central router that forwards all data operations to the active DataStorageProvider.

Tier switching: - upgradeToPaid() / downgradeToFree() — persisted to UserDefaults (hasPaidSubscription) - setCloudUserId(_ id: String) — called after Firebase sign-in to wire the cloud user ID - Free tier uses a stable local_<UUID> identifier generated on first launch

Implementations of DataStorageProvider:

Class Tier Backing store
LocalStorageService Free Core Data (NSManagedObjectContext)
CloudStorageService Paid Firestore (thin wrapper over FamilyService)

Widget sync: After any star transaction, StorageManager.syncWidgetData() serialises a WidgetFamily snapshot to the shared App Group container and calls WidgetCenter.shared.reloadAllTimelines().

FamilyService

The Firestore data layer. All CRUD operations for families, children, transactions, and goals live here. Uses Firestore transactions for balance mutations to prevent race conditions.

Firestore data structure:

families/{familyId}
  ├── (document: name, starValue, currency, inviteCode, version, lastModified)
  ├── members/{userId}          — role: admin | parent | viewer
  ├── children/{childId}
  │   ├── (document: name, avatar, currentBalance, lastModified, syncStatus)
  │   ├── transactions/{txnId}  — type: earn | spend
  │   └── goals/{goalId}        — name, targetStars, isActive, achievedAt
invites/{inviteCode}            — lookup → familyId (for join flow)

Optimistic locking: Family carries a version: Int field incremented on each write. Concurrent editors are detected via lastModifiedBy.

Error handling: All Firestore errors are logged to os.log (subsystem no.invotek.RewardE, category FamilyService) and recorded to Firebase Crashlytics.

GoalService

Extracted from AppState per Single Responsibility. Manages in-memory goal state per child — loaded at family load, cleared on sign-out. Milestone detection at 25 / 50 / 75 / 100% progress.

SyncManager

Coordinates offline/online state for paid tier only.

SyncState machine:

idle ←──── (online, no pending, no conflicts)
  ↓
offline ←── (isConnected = false)
  ↓
pendingChanges ←── (online, hasPendingWrites)
  ↓
conflict ←── (ConflictResolver.hasConflicts = true)
  ↓
syncing ←── (onConnectionRestored in progress)

SyncManager uses Combine to observe ConnectionService.$isConnected, PendingWritesTracker.$hasPendingWrites, and ConflictResolver.$hasConflicts.

On connection restore: 1. ConflictResolver.checkForBalanceConflicts — detects negative balances caused by concurrent offline writes 2. ConflictResolver.checkForDuplicateGoals — deduplicates goals per child 3. PendingWritesTracker.clearAll() — resets pending count after sync

Offline operation policy (FeatureLockPolicy): Star transactions (earn/spend) are allowed offline; structural operations (delete child, update star value, update currency) require online.

SubscriptionService

Wraps RevenueCat SDK for subscription lifecycle.

Product IDs: - Monthly: no.invotek.RewardE.premium.monthly - Yearly: no.invotek.RewardE.premium.yearly - Entitlement: premium - Offering: rewarde_default

Family subscription sharing: When any family member has an active subscription, all members get premium features. StorageManager.checkFamilySubscription() reads a subscription subcollection on the family document to check cross-platform (ios | android) entitlement.

RevenueCat ↔ Firebase: SubscriptionService.logIn(userId:) ties the RevenueCat subscriber to the Firebase UID for cross-device tracking. logOut() is called on sign-out.

PhotoStorageService

Child photos are stored locally only — never synced to Firestore. Photos are resized to max 400px and saved as JPEG (0.8 quality) in the app's Documents/ChildPhotos/ directory. photoFileName is ignored by CloudStorageService.

MigrationService

One-way migration from Core Data → Firestore when a free-tier user upgrades.

Flow: 1. Read full local family + children + transactions + goals from Core Data 2. Create cloud family in Firestore 3. Recreate each child, replay all transactions, copy goals 4. Mark migration complete in UserDefaults

Progress is published via @Published migrationProgress: Double (0.0–1.0) for UI feedback.

CutiEService

Integrates the Cuti-E feedback SDK (github.com/cuti-e/ios-sdk). Called at key UX moments to collect in-context character-driven feedback.

AppCheckService

Firebase App Check integration to prevent API abuse from non-genuine app instances.

ConnectionService

Monitors network reachability. Publishes $isConnected: Bool observed by AppState and SyncManager. Exposes onConnectionRestored callback used to trigger sync on re-connect.

NetworkRetry

Utility wrapping async operations with exponential backoff. Used by AppState.loadFamily() and loadChildren() to handle transient Firestore errors.


Data Models

All models are Codable and Identifiable. Cloud models include version/sync tracking fields decoded with decodeIfPresent for backward compatibility with pre-versioning data.

Model Key fields
Family id, name, starValue: Int, currency: SupportedCurrency, inviteCode, version: Int, lastModified, lastModifiedBy
Child id, name, avatar, photoFileName? (local only), currentBalance: Int, syncStatus: SyncStatus
Transaction id, type: .earn\|.spend, amount, reason?, awardedBy, createdAt
Goal id, name, targetStars: Int, isActive, achievedAt?
FamilyMember userId, name, role: .admin\|.parent\|.viewer
FamilySubscription active, platform: .ios\|.android, productId, expiresAt?, ownerId

Supported currencies: NOK, SEK, DKK, EUR, USD, GBP. Formatting is locale-aware (Scandinavian: 100 kr, European/US: €100/$100).

SyncStatus: .synced | .pending | .conflict — carried on Child for offline UI indicators.


Core Data Schema (Free Tier)

Four entities map 1:1 to the cloud models:

Entity Key attributes
CDFamily id, name, starValue: Int32, currency, inviteCode, ownerId
CDChild id, name, avatar, currentBalance: Int32, relationship → CDFamily
CDTransaction id, type, amount: Int32, reason, awardedBy, relationship → CDChild
CDGoal id, name, targetStars: Int32, isActive, achievedAt, relationship → CDChild

Int values use safe Int32 clamping to prevent overflow on the 32-bit Core Data type.


Widget Extension (RewardEWidget)

Target: RewardEWidget — WidgetKit, supports small / medium / large sizes.

Data flow: 1. Main app calls StorageManager.syncWidgetData() after any star mutation 2. WidgetDataProvider serialises a WidgetFamily (name + [WidgetChild]) to the shared App Group container via UserDefaults(suiteName:) 3. Widget reads via WidgetDataProvider.shared.loadFamily() in getSnapshot and getTimeline 4. Timeline refreshes every 15 minutes as fallback; WidgetCenter.shared.reloadAllTimelines() triggers immediate updates from the main app

Deep linking: Tapping a child in the widget fires reward-e://child/{childId}, handled by AppState.handleDeepLink(_:).


Firebase / Backend

Project: reward-e-app

Firestore Security Rules

Rules enforce: - All writes require authentication (isAuthenticated()) - Family members can only read/write their own family (membership check via /members/{userId}) - Field validation on all writes (isValidFamilyData, isValidMemberData, isValidSubscriptionData) - hasOnlyKeys helper prevents unknown field injection - Collection group query on members supports both single-doc get (join flow) and userId-filtered list (family lookup)

Firestore Indexes

Composite indexes support: - Transaction history queries (childId + createdAt DESC, limit 50) - Active goal queries (childId + isActive) - Goal history (childId + achievedAt DESC)

Cloud Functions (Node.js 20, Firebase Functions v2)

Located in functions/src/index.ts. Rate limiting and quota enforcement:

Trigger Limit Action on breach
onFamilyCreated 5 families/user/day Auto-deletes the excess family
Transaction writes 100/child/hour HttpsError thrown
Children per family 20 max HttpsError thrown
Goals per child 50 max HttpsError thrown

Rate limit state is stored in _rateLimits/{userId} documents using Firestore transactions for atomicity.

Firebase App Distribution

Pre-release builds are distributed via Firebase App Distribution using the firebase-app-distribution.yml workflow, triggered manually or on release candidates.


CI/CD

Runners

All workflows run on self-hosted runners: - iOS / macOS jobs: [self-hosted, macOS, ios] — Xcode Cloud-style runner with Xcode 15+, XcodeGen, SwiftLint - Android jobs: [self-hosted, macOS, ios] — macOS required for ARM64 Android SDK / AAPT2 - Backend/infra jobs: arc-linux-star-rewards — Actions Runner Controller (ARC) on Linux

Workflows

Workflow Trigger What it does
ci.yml PR → main (ios/**) SwiftLint → XcodeGen → Package.resolved validation → unit tests → Codecov upload
android-ci.yml PR → main (android/**) Gradle build (debug + release ProGuard verify)
deploy-firestore-rules.yml Push to main (firestore.rules / indexes) Authenticates via Workload Identity Federation, deploys rules + indexes, runs index verification script
deploy-functions.yml Push to main (functions/**) npm ci → build → firebase deploy --only functions
release.yml Manual dispatch / workflow_call Version bump (patch/minor/major/custom), build number increment, Xcode Cloud submission
release-please.yml Push to main Automates CHANGELOG.md and release PR generation
firebase-app-distribution.yml Manual Distribute beta builds to testers
deploy-website.yml Push to main (web/**) Deploy marketing website
build-status-to-discord.yml Workflow status events Post CI results to Discord
notify-failure.yml Workflow failure Alert on broken main
monitor-xcode-cloud.yml Scheduled Watch Xcode Cloud build health
post-release-check.yml After release Verify App Store submission state

GCP authentication: Backend workflows use Workload Identity Federation (keyless) with a dedicated service account. No long-lived keys stored in secrets.

Code coverage: iOS unit tests upload to Codecov (flags: ios). Coverage report extracted from .xcresult bundle via xcrun xccov.


Dependencies

iOS Swift Packages (ios/Package.resolved)

Package Source Purpose
firebase-ios-sdk github.com/firebase/firebase-ios-sdk Auth, Firestore, Crashlytics, App Check
purchases-ios github.com/RevenueCat/purchases-ios Subscription management
ios-sdk github.com/cuti-e/ios-sdk Cuti-E character feedback
swift-protobuf github.com/apple/swift-protobuf Protobuf support (Firebase dependency)
abseil-cpp-binary, grpc-binary google/... gRPC (Firestore dependency)
googleappmeasurement, googledatatransport google/... Firebase analytics dependencies

Cloud Functions

  • firebase-admin — Firestore server-side access
  • firebase-functions v2 — Firestore trigger and HTTPS error types

Security Model

  • Authentication: Sign in with Apple (privacy-preserving, no email required) + Firebase Auth
  • Free tier: No account. Local UUID in UserDefaults, data never leaves the device
  • Firestore rules: Server-side enforcement — clients cannot read/write other families
  • App Check: Blocks API calls from non-genuine app instances
  • Nonce-based OAuth: Each Apple sign-in uses a fresh secure random nonce (SHA-256 hashed) to prevent credential replay
  • Workload Identity Federation: CI/CD authenticates to GCP without stored service account keys
  • Rate limiting: Cloud Functions enforce per-user quotas to prevent abuse and runaway Firestore costs

Localisation

The app ships with 38 localisations (configured in project.yml): en, ar, ca, cs, da, de, el, es, es-419, fi, fr, fr-CA, he, hi, hr, hu, id, it, ja, ko, ms, nb, nl, pl, pt-BR, pt-PT, ro, ru, sk, sv, th, tr, uk, vi, zh-Hans, zh-Hant.


Key Architectural Decisions

Date Decision Rationale
2025-12-06 Firebase (Auth + Firestore) over iCloud Real-time listeners simplify sync; invite codes easier than iCloud family sharing
2025-12-06 Parent-only MVP Simpler auth flow; kids view stars on parent's device
2025-12-07 Firestore transactions for spendStars Prevents race condition: two simultaneous spends exceeding balance
2025-12-07 Auto-deploy Firestore rules via GitHub Actions Rules changes are tested and deployed atomically with code
2025-12-17 Extract GoalService from AppState AppState was growing too large; Single Responsibility
(ongoing) StorageManager provider abstraction Free tier works offline with zero Firebase dependency; upgrade path is a clean migration
(ongoing) Photos local-only Avoids Firebase Storage costs and complexity; photos are cosmetic, not business-critical