Doc status: Latest (rolling). See Versions.
High level
- Mobile App (Expo / React Native +
@react-native-firebase/authnative SDK) - Admin Web (Next.js)
- API (Cloudflare Workers — Hono)
- Data (Firestore)
- Push (FCM → APNs/Android)
- Payments (Stripe — REST API, no SDK)
- PQC Crypto (
@maatara/core-pqc— WASM, Apache-2.0)
flowchart LR
Mobile[Mobile App] -->|Firebase ID Token| API[Cloudflare Worker API]
Admin[Admin Web] -->|CF Access JWT| API
EntraID[Microsoft Entra ID] -->|OIDC SSO| CFA[Cloudflare Zero Trust Access]
CFA -->|JWT cookie| Admin
API -->|Firestore REST| DB[(Firestore)]
API -->|FCM HTTP v1| FCM[FCM]
API -->|REST| Stripe[Stripe]
Stripe -->|Webhooks| API
FCM --> APNs[APNs / iOS]
FCM --> Droid[Android]Request / auth flow
Mobile (Firebase Auth — native SDK)
- User signs in via
@react-native-firebase/auth(native SDK) using Google Sign-In or Sign in with Apple. - The native SDK initializes automatically via
google-services.json(Android) andGoogleService-Info.plist(iOS) — no JS-levelinitializeAuthcall. - App calls the Worker API with
Authorization: Bearer <FirebaseIdToken>. - Worker verifies the Firebase ID token server-side.
- Worker enforces authorization (role + membership + status) server-side.
Admin (Cloudflare Zero Trust + Microsoft Entra ID)
- Admin visits the dashboard — Cloudflare Access intercepts and redirects to Microsoft Entra ID SSO.
- After SSO, CF Access sets a signed
CF_AuthorizationJWT cookie. - Admin app reads the cookie and forwards it as
cf-access-jwt-assertionheader to the Worker API. - Worker verifies the CF Access JWT against the team's JWKS endpoint.
- Worker maps the Entra email to a Firestore user (auto-provisions a stub if new).
- Worker enforces authorization (role + membership + status) server-side.
Common
- Worker reads/writes Firestore via REST using a service account.
- Worker triggers push fan-out using FCM HTTP v1.
Authorization model
Standard users (mobile only) can:
- register device
- join group via invite
- trigger incident (only for groups they are an active member of, with active license)
- view their own profile and notifications
Managers can (scoped to their groups):
- create groups and invite codes
- broadcast messages
- pause/ban members
- view group members, invites, payments, license status
- initiate Stripe checkout for group license
Super admins (platform-wide) can:
- view all users, groups, licenses, payments
- set user roles (standard/manager/super_admin)
- grant and revoke licenses
- all manager capabilities across all groups
Server-side checks are mandatory for all privileged actions.
Data model (logical)
This is the conceptual structure (implemented in Firestore collections/subcollections):
users/{uid}- profile:
email,displayName,role,status devices/{deviceId}:platform,token,createdAt,lastSeenAt
- profile:
groups/{groupId}- metadata:
name,zoneId,createdByUid,createdAt members/{uid}:role(member/manager),status(active/paused/banned)incidents/{incidentId}: incident records
- metadata:
invites/{inviteCode}groupId,expiresAt,maxUses,uses,revoked
licenses/{licenseId}groupId,status(active/expired/revoked/trial),type(paid/granted)stripeSubscriptionId,stripeCustomerId,grantedByUidstartsAt,expiresAt
payments/{paymentId}licenseId,groupId,adminUidstripePaymentIntentId,amount,currency,status
notifications_log/{notifId}type,targetUid,groupId,message,read
Security model
- All privileged actions are checked server-side:
- Mobile auth: Firebase ID token verification
- Admin auth: Cloudflare Access JWT verification (JWKS), backed by Microsoft Entra ID SSO
- Authorization: group manager role checks in Firestore
- Admin dashboard is gated by Cloudflare Zero Trust — only authorised Entra ID users can reach it.
- Avoid storing exact addresses; prefer coarse zones.
Platform ownership
Owned and maintained by RME Solutions Technology Australia.
Address handling (on-device + encrypted)
N-Watch does not store a user’s exact home address centrally.
- The address is stored only on the user’s phone and can be edited at any time.
- The address is sent only when an alert is triggered, as an encrypted payload.
- The system should persist no plaintext address server-side (logs or database). If stored at all, store only an opaque encrypted blob.
Encrypted incident payload (PQC-ready, locked suite)
Locked crypto suite:
- Key transport: ML-KEM-768
- Signatures: ML-DSA-44 (optional but recommended for integrity)
- Payload: AES-256-GCM
Important detail: ML-DSA is a signature algorithm (authenticity/integrity), not encryption. For encrypting/wrapping a per-incident symmetric key we use ML-KEM. Payload encryption remains AES-256-GCM.
Keys
- Each device generates a KEM keypair on first sign-in using the
@neighbourhoodwatch/pqc-nativeExpo module (Rust → FFI). - The secret key is stored in the OS secure enclave via
expo-secure-store(Android Keystore / iOS Keychain) — never leaves the device, never in plain-text storage. - The public key is uploaded to Firestore at
users/{uid}/crypto/{kid}during sign-in and again at invite redemption so other group members can encrypt for this user.
Per-user keys:
- KEM keypair: ML-KEM-768 for wrapping a per-incident content key.
- Signature keypair: ML-DSA-44 (optional, for integrity verification).
Firestore: users/{uid}/crypto/{kid} → kemPublicKey, kid, createdAt
Payload flow (true E2E — server never sees plaintext)
Live in production since v0.3.0.
- On first sign-in the app generates an ML-KEM-768 keypair; the secret stays in Keychain/Keystore; the public key +
kidare uploaded tousers/{uid}/crypto/{kid}. - When a user joins a group (invite redemption), the public key is also uploaded to ensure it's available.
- When the sender taps ALERT, the app calls
GET /v1/groups/:groupId/keysto fetch every active member's KEM public key. - The sender's app encrypts the address independently for each recipient on-device:
KEM-Encaps(recipientPK)→kemCiphertext+sharedSecretAES-256-GCM-Encrypt(sharedSecret, address)→ciphertext+nonce
- All per-recipient envelopes
{ uid, kid, kemCiphertext, ciphertext, nonce }are sent to the Worker in thetriggerIncidentrequest. - The Worker stores envelopes opaquely at
groups/{groupId}/incidents/{incidentId}/envelopes/{uid}— zero-knowledge: server never decrypts. - FCM push contains only metadata:
incidentId,groupId,kind,hasEnvelope. No secrets or ciphertext in the push. - Recipient calls
GET /v1/incidents/:groupId/:incidentId/envelopeto fetch their opaque envelope. - Recipient decrypts locally:
KEM-Decaps→sharedSecret→AES-256-GCM-Decrypt→ plaintext address. - Decrypted location shown in an in-app alert.
What never travels over the wire: private keys, plaintext addresses, shared secrets.
Emergency alert capabilities
- Android: Channel
alerts—MAXimportance,bypassDnd: true. - iOS:
apns-priority: 10, Critical Alert (critical: 1).
See also: Security.
Static “waterfall” delivery plan
The requirements are intentionally clear and narrow; this supports a straightforward waterfall delivery sequence:
- Requirements sign-off (roles, invite join, alerting, admin controls, data minimisation)
- System design sign-off (API contracts + data model + security invariants)
- Implementation
- Worker API + Firestore schema
- Mobile minimal UX
- Admin minimal UX
- Verification
- Contract tests (local/staging)
- Staging E2E tests
- Deployment
- Worker deploy
- Mobile release pipeline
- Admin deploy
- Operational hardening
- Monitoring + alerting
- Incident review process
Content & diagrams
- Put images in
docs/public/images/and reference as/images/.... - Use Mermaid blocks (like above) for flow diagrams.

