Appearance
Architecture
Doc status: Latest (rolling). See Versions.
Design principle
NWatch is architecturally designed so membership remains scoped to invited groups. The manager controls membership and the API enforces it. The mobile home surface operates one primary group at a time; the manager portal can operate multiple groups. See Security for the full trust model.
High level
- Mobile App (Expo SDK 55 / React Native +
@react-native-firebase/authnative SDK) - Admin Web (Next.js on Cloudflare Workers via vinext)
- API (Cloudflare Workers — Hono)
- Data (Firestore)
- Push (FCM → APNs/Android)
- Payments (Stripe — REST API, no SDK)
- PQC Crypto (iOS native CryptoKit via
@neighbourhoodwatch/pqc-native+ JS fallbackmlkem/@noble/ciphers)
mermaid
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, a passwordless email link, or Microsoft when configured; Sign in with Apple is also available on iOS. - 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.
Super-admin console (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
- pause/remove members and manage member roles
- review member device readiness
- reveal one incident's encrypted details through an audited workflow and resolve incidents
- view group members, invites and licence status
- manage seats, billing and Stripe checkout for group licences
Super admins (platform-wide) can:
- view all users, groups, licenses, payments
- set user roles (standard/manager/super_admin)
- grant and revoke licenses
- perform platform administration across groups; plaintext incident reveal remains separately gated by direct active group membership
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
system/cryptokemPublicKey,kid,updatedAt— the server-managed system KEM identity
Security model
- All privileged actions are checked server-side:
- Mobile auth: Firebase ID token verification
- Manager portal auth: Firebase ID token verification
- Super-admin auth: Cloudflare Access JWT verification (JWKS), backed by Microsoft Entra ID SSO
- Authorization: group manager role checks in Firestore
- The super-admin console is gated by Cloudflare Zero Trust — only authorised Entra ID users can reach it.
- Do not persist saved addresses as plaintext profile fields; use coarse zones for group organisation and encrypted incident envelopes for sent address/GPS snapshots.
Platform ownership
The current contracting entity and operator details are maintained in the public Terms and Privacy Policy.
Address handling (on-device + encrypted)
NWatch does not store a user's saved alert address as a plaintext profile field.
- The saved address is stored on the user's phone and can be edited at any time. Once sent, that alert snapshot is also retained server-side only inside encrypted envelopes.
- The alert text is sent only when an alert is triggered, as an encrypted payload.
- The server stores opaque per-recipient envelopes and, when supplied by the client, a system recovery envelope that supports audited reveal and re-wrap operations.
- Plaintext is never persisted server-side — decryption is transient per request and audit-logged.
Encrypted incident payload (PQC-ready, locked suite)
Locked crypto suite:
- Key transport: ML-KEM-768
- Payload: AES-256-GCM
The current wire path uses the ML-KEM shared secret directly as the AES-256-GCM key. It does not currently use an HKDF step or ML-DSA signatures; those must not be represented as shipped protections.
Keys
- Each device generates a KEM keypair on first sign-in using a native-first strategy: iOS uses CryptoKit via
@neighbourhoodwatch/pqc-nativewhen available (AES-GCM on iOS 13+, ML-KEM-768 on iOS 18.2+), with automatic fallback to pure-JS (mlkem+@noble/ciphers) when native APIs are unavailable. - The secret key is stored through
expo-secure-storein OS-protected Android Keystore / iOS Keychain storage — never uploaded or placed in ordinary plaintext app storage. - Secret keys are retained by
kidin secure storage so older envelopes can still be decrypted after key rotation. - Users can manually rotate their keys at any time from the settings menu.
- 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. - One active device per account — simultaneous multi-device use is not supported. A replacement phone may sign in with the same linked provider; registering its active key may supersede the previous device for new envelopes.
Per-user keys:
- KEM keypair: ML-KEM-768 for wrapping a per-incident content key.
Firestore: users/{uid}/crypto/{kid} → kemPublicKey, kid, createdAt
System key (server identity)
- The server holds its own ML-KEM-768 keypair for system-level operations.
- Secret key → Cloudflare Worker secret
PQC_KEM_SECRET_KEY(+PQC_KEM_KID). - Public key → Firestore
system/cryptodocument, bootstrapped viaPOST /v1/superadmin/bootstrapSystemKey. - Returned alongside member keys by
/v1/groups/:groupId/keys(uidnwatch-sys). Current clients include a service-recovery envelope when that key is available; missing recovery envelopes are not proof that an incident was not stored for member recipients. - Enables: admin decrypt (view alert text), manager re-wrap (after user key rotation), and system key rotation.
Payload flow (per-recipient encryption with a service-recovery envelope when available)
Live in production since v0.3.0.
- On first sign-in the app generates an ML-KEM-768 keypair (native-first with pure-JS fallback); 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 slides the alert control right and holds it for two seconds, completing the hold makes the app call
GET /v1/groups/:groupId/keysto fetch active member KEM public keys and the system key (uidnwatch-sys) when configured. - The sender's app attempts to encrypt the alert text independently for each returned recipient key on-device, including the system key when available:
KEM-Encaps(recipientPK)→kemCiphertext+sharedSecretAES-256-GCM-Encrypt(sharedSecret, alertText)→ciphertext+nonce
- Only successfully sealed per-recipient envelopes
{ uid, kid, kemCiphertext, ciphertext, nonce }are sent to the Worker in thetriggerIncidentrequest. A failed recipient seal is reported to the sender as a skipped key; if every recipient seal fails, the incident is not submitted. - The Worker stores returned envelopes at
groups/{groupId}/incidents/{incidentId}/envelopes/{uid}. A current client normally includesnwatch-syswhen the system key was available. - FCM push contains only metadata:
incidentId,groupId,kind,hasEnvelope. No secrets or ciphertext in the push. - The sender is excluded from the FCM fan-out — the sender already sees the alert on-screen, and alerting their own phone could be dangerous if they are under duress.
- An eligible recipient for whom an envelope was successfully sealed can call
GET /v1/incidents/:groupId/:incidentId/envelopeto fetch that opaque envelope. - That recipient device attempts local decryption:
KEM-Decaps→sharedSecret→AES-256-GCM-Decrypt→ plaintext. - Decrypted location shown in an in-app alert. GPS coordinates are rendered as tappable links that open in the native maps app.
- Manager operations: when an incident has a valid
nwatch-sysenvelope, an active group manager can explicitly reveal it in the manager portal; every reveal is audit logged. Controlled administrative tooling can also re-wrap for a user who rotated keys.
What never leaves the device: member private keys and ML-KEM shared secrets. Alert plaintext enters the service only transiently when an authorised manager explicitly requests an audit-logged reveal over TLS; it is not persisted as plaintext.
Emergency alert capabilities
- Android: Channel
alertsis created natively withAudioAttributes.USAGE_ALARM, high importance, public lock-screen visibility, and custom sirenalarm.wav. Full-screen and DND behavior depends on Android policy, user settings and special access. The app neither changes global DND mode nor raises system alarm volume, and it no longer requests overlay permission. - iOS: Incident pushes use APNs alert payload (
aps.alert) withapns-priority: 10and Critical Alert sound object (sound.critical: 1,name: alarm.wav,volume: 1.0) plusinterruption-level: critical. - Permission hardening: Mobile startup runs a readiness check for ordinary notifications, iOS critical-alert authorisation, Android full-screen intent access and channel settings. The home screen preserves a visible readiness warning until required device settings are reviewed.
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.