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:
- No third-party SDK lock-in โ provider switching requires only config changes
- Full observability โ every push flows through the existing AlertDeliveryService DLQ pipeline
- 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.
Navigation Policy
- Links within
*.mallnline.comopen 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
- APNs JWT rotation: Tokens are cached for 50 minutes (Apple's max is 60 minutes) and regenerated automatically
- FCM OAuth2 rotation: Access tokens cached for 58 minutes (Google's max is 3600 seconds) with automatic refresh
- No keys in source: All provider credentials are loaded from environment variables, never committed to the repository
- Token injection scope:
window.__NGWENYA_TOKEN__is injected atdocumentStart(iOS) /onPageFinished(Android), limiting exposure to the WebView context - WebView navigation guard: External URLs are intercepted and opened in the system browser, preventing credential harvesting in untrusted contexts
- Platform validation: Only
'ios'and'android'are accepted as valid platform values ingetMobilePlatform()โ any other value returnsnull
Related
- iOS Development Setup โ Xcode project creation, signing, and simulator/device testing
- Android Development Setup โ Android Studio, Gradle, Firebase, and emulator setup
- Mobile SDK Architecture โ KMP shared module, OIDC/PKCE auth adapters, and uCart widget SDK
- Alerts Resilience & Delivery Tracking โ DLQ pipeline and AlertLog that wrap all push dispatch attempts
- Notification Connection Modes โ WebSocket vs polling transport for in-app notifications
- Nodes โ User Profiles & Social Graph โ
pushTokensarray on the User entity - Invite & Notification Pipeline โ Cross-subgraph event flow for organization notifications
- Handle System & Sigil Taxonomy โ
v|handledisplay in native screens - User Guide: Notifications โ Visitor-facing notification management guide