Saltar a contenido

Architecture

Package Dependency Graph

@cyber-eco/types        (zero runtime deps)
       |
       +----------------------+
       |                      |
       v                      v
@cyber-eco/firebase     @cyber-eco/auth
  (peer: firebase)       (peer: firebase, react?, jsonwebtoken?)
       |                      |
       |    +-----------------+
       |    |
       v    v
@cyber-eco/services
  (NO firebase dependency)

Why this graph matters

@cyber-eco/services sits at the top of the dependency chain but has no dependency on @cyber-eco/firebase. It only depends on: - @cyber-eco/types — for the StorageAdapter interface and data model types - @cyber-eco/auth — for error classes and the logger utility

The Firebase connection happens at runtime, when the consumer calls createDataLayer({ adapter: new FirebaseStorageAdapter(...) }). This is the architectural foundation for storage agnosticism.

StorageAdapter Pattern

The Interface

interface StorageAdapter {
  // Document CRUD
  getDocument<T>(collection: string, id: string): Promise<T | null>;
  setDocument<T>(collection: string, id: string, data: T, options?: WriteOptions): Promise<WriteResult>;
  updateDocument(collection: string, id: string, data: Record<string, unknown>): Promise<WriteResult>;
  deleteDocument(collection: string, id: string): Promise<WriteResult>;

  // Queries
  query<T>(collection: string, filters: QueryFilter[], options?: QueryOptions): Promise<PaginatedResult<T>>;

  // Batch
  batchWrite(operations: BatchOperation[]): Promise<BatchResult>;

  // Real-time
  subscribe<T>(collection: string, id: string, callback: (data: T | null) => void): Unsubscribe;
  subscribeToQuery<T>(collection: string, filters: QueryFilter[], callback: (data: T[]) => void): Unsubscribe;

  // Utilities
  serverTimestamp(): unknown;
  generateId(collection: string): string;
}

Why It Exists

Every domain service needs to read and write data. Without the adapter, each service would import firebase/firestore directly, creating an unbreakable dependency on Firebase. The adapter inverts this: services depend on an interface, and the concrete implementation is injected at construction time.

How to Implement a New Adapter

  1. Create a new package (e.g., @cyber-eco/ipfs)
  2. Implement the StorageAdapter interface
  3. Pass it to createDataLayer({ adapter: new IPFSStorageAdapter(...) })
  4. All domain services work unchanged

DataLayerService Orchestration

The DataLayerService is the central orchestrator. It coordinates five concerns:

Read Flow

Request: get(userId, collection, id)
  |
  +- 1. Permission Check
  |     +- Call permissionChecker(userId, collection, 'read', id)
  |     +- If denied -> throw AuthorizationError
  |
  +- 2. Cache Lookup
  |     +- key = `${collection}:${id}`
  |     +- If hit -> return cached value
  |
  +- 3. Adapter Fetch
  |     +- adapter.getDocument(collection, id)
  |
  +- 4. Cache Set
  |     +- cache.set(key, result, ttl[collection])
  |
  +- 5. Return result

Write Flow

Request: create(userId, collection, data)
  |
  +- 1. Permission Check
  |     +- Call permissionChecker(userId, collection, 'write')
  |     +- If denied -> throw AuthorizationError
  |
  +- 2. Enrich Data
  |     +- Add id, createdAt, createdBy
  |
  +- 3. Adapter Write
  |     +- adapter.setDocument(collection, id, enrichedData)
  |
  +- 4. Cache Invalidate
  |     +- cache.deletePattern(`${collection}:*`)
  |     +- cache.set(`${collection}:${id}`, enrichedData)
  |
  +- 5. Sync Broadcast
  |     +- sync.broadcast({ type: 'created', collection, documentId, data })
  |
  +- 6. Webhook Emit
  |     +- webhooks.emit({ type: `${collection}.created`, payload })
  |
  +- 7. Return id

Subscription Flow

Request: subscribe(userId, collection, id, callback)
  |
  +- 1. Permission Check (once, at subscription time)
  |
  +- 2. Delegate to adapter.subscribe(collection, id, wrappedCallback)
  |     +- wrappedCallback updates cache, then calls user's callback
  |
  +- 3. Return unsubscribe function

CacheService Architecture

+----------------------------------+
|         CacheService             |
|                                  |
|  +------------+  +------------+  |
|  |  L1 Cache  |  |  L2 Cache  |  |
|  | (In-Memory |  | (Optional  |  |
|  |    LRU)    |  |  Backend)  |  |
|  | 1000 items |  |  e.g Redis |  |
|  +------------+  +------------+  |
|                                  |
|  TTL Strategy (per collection):  |
|  users: 5min                     |
|  permissions: 2min               |
|  notifications: 30sec            |
|  transactions: 1min              |
|  groups: 5min                    |
|  default: 3min                   |
+----------------------------------+
  • L1 is always present (built-in InMemoryLRUCache)
  • L2 is optional — plugged in via the CacheBackend interface
  • On read: check L1 -> check L2 -> miss. On L2 hit, promote to L1.
  • On write: write to both L1 and L2
  • deletePattern(pattern) invalidates matching keys across all levels
  • staleWhileRevalidate: optionally serve stale data while fetching fresh

Permission Model

4-Tier Role Hierarchy

owner (4)  > admin (3) > moderator (2) > member (1)

hasMinimumRole(userRole, requiredRole) compares numeric values.

AppPermission (merged model)

interface AppPermission {
  appId: string;
  roles: AppRole[];
  features: string[];
  grantedAt: string;
  grantedBy: string;
  conditions?: PermissionCondition[];
}

Conditional Access

interface PermissionCondition {
  type: 'time' | 'ip' | 'mfa' | 'custom';
  config: Record<string, unknown>;
}

Conditions are evaluated at access time. All conditions must pass for permission to be active.

  • time: { after: "2025-01-01", before: "2025-12-31" } — permission valid only within date range
  • ip: { allowList: ["192.168.1.0/24"] } — permission valid only from specified networks
  • mfa: { required: true } — permission requires verified MFA session
  • custom: extensible for future condition types

Factory Bootstrap

The createDataLayer() factory solves the circular dependency between DataLayerService and PermissionService:

function createDataLayer(config: DataLayerConfig): CyberEcoDataLayer {
  // 1. Create core without permission checking
  const core = new DataLayerService(config);

  // 2. Create PermissionService that uses core for data access
  const permissions = new PermissionService(core);

  // 3. Wire permission checking back into core
  core.setPermissionChecker(permissions.evaluatePermission.bind(permissions));

  // 4. Create remaining domain services
  return {
    core,
    permissions,
    sharedData: new SharedDataService(core),
    dashboard: new DashboardService(core),
    dataExport: new DataExportService(core),
    notifications: new NotificationService(core),
  };
}

Collection Schema

Collection Document Type Owner Purpose
users HubUser / SharedUserProfile Hub Central user records
transactions Transaction Hub Cross-app financial records
groups SharedGroup Hub Cross-app group memberships
notifications Notification Hub Cross-app notification queue
permissions ResourcePermission Hub Fine-grained resource access
resourcePermissions ResourcePermission Hub Resource-level perms (compound key)
permissionLogs PermissionChangeLog Hub Audit trail
userPreferences UserPreferences Hub Currency, locale prefs
dataSyncStatus DataSyncStatus Hub Cross-app sync state
apps App Hub App registry
webhookRegistrations WebhookRegistration Hub External webhook endpoints
expenses Expense JustSplit App-specific expenses
events Event JustSplit App-specific events
settlements Settlement JustSplit App-specific settlements
friendships Friendship Shared Social connections

Future: Transport Adapter Pattern

Parallel to StorageAdapter (which abstracts where data is stored), the TransportAdapter abstracts how data moves between devices:

interface TransportAdapter {
  start(): Promise<void>;
  stop(): Promise<void>;
  send(peerId: string, message: NetworkMessage): Promise<void>;
  broadcast(message: NetworkMessage): Promise<void>;
  onMessage(callback: (peerId: string, message: NetworkMessage) => void): Unsubscribe;
  getPeers(): PeerInfo[];
  getTransportType(): TransportType;
}

Implementations: InternetTransport (WebRTC/libp2p), BluetoothTransport (BLE mesh), WiFiDirectTransport, LoRaTransport, WiredTransport (LAN). A MultiTransportManager selects the best transport for each message based on size, urgency, and available networks.

See P2P Architecture for full details.

Future: Encrypted Computation Layer

An EncryptedStorageAdapter decorator wraps any StorageAdapter to add transparent client-side encryption:

Write: permissions -> encrypt -> adapter write -> cache -> sync -> return
Read:  permissions -> cache -> adapter fetch -> decrypt -> cache set -> return

Three computation methods for different privacy/performance tradeoffs: - FHE — Compute on encrypted data (batch analytics, ~1000x slower) - TEE — Hardware enclaves (real-time queries, ~1.2x slower) - MPC — Multi-party computation (group operations, network-bound)

See Encrypted Computation for full details.

Future Package Graph

@cyber-eco/types        (zero runtime deps)
       |
       +----------+----------+----------+
       |          |          |          |
       v          v          v          v
@cyber-eco/    @cyber-eco/  @cyber-eco/  @cyber-eco/
  firebase      auth        p2p        crypto
       |          |          |          |
       +----+-----+----+----+----+-----+
            |          |         |
            v          v         v
      @cyber-eco/  @cyber-eco/  @cyber-eco/
       services      crdt       fhe

All future packages depend only on @cyber-eco/types, maintaining the storage-agnostic constraint.