Developer Docs

Mobile Push Infrastructure โ€” Developer Guide

Feature: In-House Push Dispatch + Native App Shells
Status: Phase 1 Complete โ€” Native Integration Ready
Subgraphs: alerts, nodes
Repos: ngwenya-ios ยท ngwenya-android


Overview

The Mobile Push Infrastructure is the platform's native notification delivery layer. Unlike typical implementations that lean on Firebase Admin SDK, Mallnline uses a zero-dependency in-house approach โ€” dispatching directly to Apple Push Notification service (APNs) via HTTP/2 and Firebase Cloud Messaging (FCM) via HTTP v1, using only Node.js built-in http2 and crypto modules.

This architecture ensures:

  1. No third-party SDK lock-in โ€” provider switching requires only config changes
  2. Full observability โ€” every push flows through the existing AlertDeliveryService DLQ pipeline
  3. Platform parity โ€” iOS and Android shells share identical navigation, bridge contracts, and push registration flows
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                       Native App Layer                           โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚  iOS (SwiftUI)      โ”‚      โ”‚  Android (Jetpack Compose)  โ”‚   โ”‚
โ”‚  โ”‚  WKWebView Bridge   โ”‚      โ”‚  WebView Bridge             โ”‚   โ”‚
โ”‚  โ”‚  APNs Token โ†’ GQL   โ”‚      โ”‚  FCM Token โ†’ GQL            โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
              โ”‚ registerPushToken(token,   โ”‚
              โ”‚ platform: "ios"|"android") โ”‚
              โ–ผ                            โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚             Ngwenya Federated Gateway (port 30000)                โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚ auth โ”‚  โ”‚ nodes                 โ”‚  โ”‚ alerts             โ”‚    โ”‚
โ”‚  โ”‚      โ”‚  โ”‚ registerPushToken GQL โ”‚  โ”‚ PushAlertsService  โ”‚    โ”‚
โ”‚  โ”‚      โ”‚  โ”‚   โ”€โ”€โ”€ TCP emit โ”€โ”€โ–บ   โ”‚  โ”‚   APNs HTTP/2      โ”‚    โ”‚
โ”‚  โ”‚      โ”‚  โ”‚                      โ”‚  โ”‚   FCM HTTP v1      โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Push Dispatch Service

Architecture

The PushAlertsService in the alerts subgraph handles direct dispatch to both Apple and Google push providers:

Provider Protocol Auth Method Token Caching
APNs (iOS) HTTP/2 to api.push.apple.com ES256 JWT signed with .p8 key 50-minute refresh
FCM (Android) HTTPS to fcm.googleapis.com/v1 RS256 JWT โ†’ OAuth2 token exchange 58-minute refresh

Data Flow

sequenceDiagram
    participant App as Native App
    participant GW as Gateway
    participant Nodes as nodes subgraph
    participant Alerts as alerts subgraph
    participant APNs as Apple APNs
    participant FCM as Google FCM

    App->>GW: registerPushToken(token, platform)
    GW->>Nodes: GraphQL mutation
    Nodes->>Nodes: Persist token in User.pushTokens
    Nodes-->>Alerts: TCP: register_push_token

    Note over Alerts: Later, when a notification is triggered...

    Alerts->>Alerts: AlertDeliveryService.sendPush()
    Alerts->>Nodes: TCP: get user push tokens
    Nodes-->>Alerts: [{ token, platform }]
    
    alt platform = ios
        Alerts->>APNs: HTTP/2 POST (JWT auth)
        APNs-->>Alerts: 200 OK / apns-id
    else platform = android
        Alerts->>FCM: HTTPS POST (OAuth2 bearer)
        FCM-->>Alerts: 200 OK / messageId
    end

Graceful Degradation

The service logs a warning and skips dispatch when environment variables are not configured โ€” it never crashes. This allows development and staging environments to run without Apple/Google credentials.

// On module init, the service checks for config:
if (!this.apnsConfig) {
  this.logger.warn('APNs config not set โ€” iOS push disabled');
}
if (!this.fcmConfig) {
  this.logger.warn('FCM config not set โ€” Android push disabled');
}

TCP Event Patterns

Three event patterns connect the nodes subgraph to the alerts push layer:

`register_push_token`

Emitted by UserMutationResolver in the nodes subgraph when a mobile client registers a device token via the registerPushToken GraphQL mutation.

interface RegisterPushTokenDto {
  userId: string;
  token: string;
  platform: 'ios' | 'android';
}

`unregister_push_token`

Emitted on logout or token invalidation via the unregisterPushToken mutation.

interface UnregisterPushTokenDto {
  userId: string;
  token: string;
}

`send_push_notification`

Used by any subgraph to request push dispatch without directly depending on the push infrastructure. This is how murchases, community, and other services trigger mobile pushes.

interface SendPushDto {
  userId: string;
  tokens: string[];   // device tokens (fetched from nodes)
  title: string;
  body: string;
  data?: Record<string, string>;  // deep-link metadata
}

GraphQL API

Register Push Token

mutation RegisterPushToken($token: String!, $platform: String!) {
  registerPushToken(token: $token, platform: $platform) {
    userId
    pushTokens
  }
}

The platform argument ("ios" or "android") is required โ€” it determines which provider the dispatch service uses when sending notifications to this token.

Unregister Push Token

mutation UnregisterPushToken($token: String!) {
  unregisterPushToken(token: $token) {
    userId
  }
}

Native App Shells

Both iOS and Android apps are maintained as independent sub-repos, mounted as git submodules in ngwenya-front:

ngwenya-front/
โ”œโ”€โ”€ .gitmodules
โ””โ”€โ”€ mobile/
    โ”œโ”€โ”€ ios/     โ†’ github.com/mall-dev/ngwenya-ios
    โ””โ”€โ”€ android/ โ†’ github.com/mall-dev/ngwenya-android

Tab Navigation (6 Tabs)

Both platforms share the same navigation structure:

Tab Screen Type Description
Lobby Discovery feed Native Personalized recommendations
Malet Storefront WebView Complex Malet browsing via WebView Bridge
uCart Universal Cart Native Cart management and checkout
Murchases Order history Native Past Murchase records
Notifications Alerts center Native Push notification inbox
uChat Messaging Native uChat conversations

iOS Shell (`ngwenya-ios`)

Built with SwiftUI targeting iOS 17+:

Ngwenya/
โ”œโ”€โ”€ NgwenyaApp.swift              # App entry + APNs delegate
โ”œโ”€โ”€ Views/
โ”‚   โ”œโ”€โ”€ ContentView.swift         # 6-tab TabView shell
โ”‚   โ”œโ”€โ”€ LobbyView.swift           # Native feed
โ”‚   โ”œโ”€โ”€ MaletView.swift            # WebView delegate
โ”‚   โ”œโ”€โ”€ UCartView.swift            # Native cart
โ”‚   โ”œโ”€โ”€ MurchasesView.swift        # Native order history
โ”‚   โ”œโ”€โ”€ NotificationsView.swift
โ”‚   โ””โ”€โ”€ UChatView.swift            # Native messaging
โ”œโ”€โ”€ Bridge/
โ”‚   โ””โ”€โ”€ WebViewBridge.swift        # WKWebView + JS bridge
โ””โ”€โ”€ Services/
    โ”œโ”€โ”€ AuthManager.swift          # JWT token management
    โ””โ”€โ”€ PushNotificationManager.swift  # GraphQL registration

Push registration flow: AppDelegate receives APNs device token โ†’ hex-encodes โ†’ calls PushNotificationManager.registerToken(token, platform: "ios") โ†’ GraphQL mutation โ†’ nodes persists + emits TCP event to alerts.

Android Shell (`ngwenya-android`)

Built with Jetpack Compose + Material3:

com/mallnline/ngwenya/
โ”œโ”€โ”€ NgwenyaApp.kt                  # Application + notification channel
โ”œโ”€โ”€ MainActivity.kt               # Compose shell + bottom nav
โ”œโ”€โ”€ ui/
โ”‚   โ””โ”€โ”€ Screens.kt                # 6 screen composables
โ”œโ”€โ”€ services/
โ”‚   โ””โ”€โ”€ NgwenyaFirebaseService.kt  # FCM token handler
โ””โ”€โ”€ bridge/
    โ””โ”€โ”€ WebViewBridge.kt           # WebView + token injection

Push registration flow: NgwenyaFirebaseService.onNewToken() receives FCM token โ†’ calls PushTokenManager.registerToken(token, "android") โ†’ GraphQL mutation โ†’ nodes persists + emits TCP event to alerts.


WebView Bridge

Complex Malet storefronts are rendered in a native WebView with auth token injection. This allows the native app to reuse the SvelteKit frontend for screens that aren't yet natively implemented.

Token Injection

Both platforms inject two globals at document start, before page content loads:

window.__NGWENYA_TOKEN__ = '<JWT>';        // Auth token for API calls
window.__NGWENYA_PLATFORM__ = 'ios';       // 'ios' | 'android'

Frontend Detection

The SvelteKit frontend detects WebView context via mobileUtils.ts:

import {
  isMobileWebView,
  getMobilePlatform,
  getNativeAuthToken,
  sendBridgeMessage,
} from '$lib/utils/mobileUtils';

// Check if running inside a native WebView
if (isMobileWebView()) {
  const platform = getMobilePlatform(); // 'ios' | 'android' | null
  const token = getNativeAuthToken();   // JWT or null
}

// Send actions to the native shell
sendBridgeMessage('navigate', { route: '/cart' });
sendBridgeMessage('addToCart', { productId: 'prod_123' });

Bridge API

Direction iOS Android
Web โ†’ Native webkit.messageHandlers.ngwenyaBridge.postMessage({action, ...}) NgwenyaBridge[action](JSON.stringify(data))
Native โ†’ Web webView.evaluateJavascript(...) webView.evaluateJavascript(...)

The sendBridgeMessage() utility in mobileUtils.ts abstracts this difference โ€” frontend code uses a single API regardless of platform.

  • Links within *.mallnline.com open in the WebView
  • External links open in the system browser (Safari / Chrome)

Environment Variables

APNs (iOS Push)

Variable Required Description
APNS_KEY_ID Yes Key ID from the Apple Developer Portal
APNS_TEAM_ID Yes Team ID from the Apple Developer Portal
APNS_KEY_CONTENTS Yes .p8 private key contents (ES256)
APNS_BUNDLE_ID No iOS app bundle ID (default: com.mallnline.ngwenya)
APNS_PRODUCTION No true for production, false for sandbox (default: false)

FCM (Android Push)

Variable Required Description
FCM_PROJECT_ID Yes Google Cloud project ID
FCM_CLIENT_EMAIL Yes Service account email
FCM_PRIVATE_KEY Yes Service account private key (PEM format)

Graceful degradation: When these variables are not set, the push service logs a warning and returns a no-op. No requests are made to Apple or Google. This is the default behavior in local development.


Module Structure

apps/alerts/src/push-alerts/
โ”œโ”€โ”€ push-dispatch.interface.ts       # PushPlatform, PushPayload, PushResult, ApnsConfig, FcmConfig
โ”œโ”€โ”€ push-alerts.service.ts           # APNs HTTP/2 + FCM HTTP v1 dispatch
โ”œโ”€โ”€ push-alerts.service.spec.ts      # 8 unit tests
โ”œโ”€โ”€ push-alerts.controller.ts        # TCP event handlers (register/unregister/send)
โ”œโ”€โ”€ push-alerts.controller.spec.ts   # 4 unit tests
โ””โ”€โ”€ push-alerts.module.ts            # NestJS module wiring

Testing

Backend (Alerts Subgraph)

# Run push-alerts unit tests (12 tests across 2 suites)
npm run test -- --testPathPattern=push-alerts

Key test coverage:

  • PushAlertsController: Token registration/unregistration logging, cross-service dispatch delegation
  • PushAlertsService: Graceful degradation without config, batch dispatch, platform detection, payload formatting

Frontend (WebView Bridge Detection)

# Run mobileUtils unit tests (15 tests)
npx vitest run tests/mobileUtils.test.ts

Key test coverage:

  • Platform detection (isMobileWebView, getMobilePlatform, isIOSWebView, isAndroidWebView)
  • Token retrieval (getNativeAuthToken) โ€” null, empty string, valid JWT
  • Bridge messaging โ€” iOS WKWebView handler, Android JSInterface, missing bridge graceful no-op

Cross-Service Dependency Map

Service Depends On Via Purpose
alerts nodes TCP Fetch user push tokens for dispatch
nodes alerts TCP (register_push_token) Forward token registration events
nodes alerts TCP (unregister_push_token) Forward token removal events
murchases alerts TCP (send_push_notification) Murchase status push notifications
community alerts TCP (send_push_notification) Assignment and mention notifications
iOS App nodes GraphQL registerPushToken mutation
Android App nodes GraphQL registerPushToken mutation

Security Considerations

  1. APNs JWT rotation: Tokens are cached for 50 minutes (Apple's max is 60 minutes) and regenerated automatically
  2. FCM OAuth2 rotation: Access tokens cached for 58 minutes (Google's max is 3600 seconds) with automatic refresh
  3. No keys in source: All provider credentials are loaded from environment variables, never committed to the repository
  4. Token injection scope: window.__NGWENYA_TOKEN__ is injected at documentStart (iOS) / onPageFinished (Android), limiting exposure to the WebView context
  5. WebView navigation guard: External URLs are intercepted and opened in the system browser, preventing credential harvesting in untrusted contexts
  6. Platform validation: Only 'ios' and 'android' are accepted as valid platform values in getMobilePlatform() โ€” any other value returns null