Architecture
Components
┌────────────────────┐
│ Sender │ any system that can make an HTTP request
│ (curl, CI, app) │
└────────┬───────────┘
│ HTTPS
▼
┌────────────────────┐
│ Prelay Server │ Go, single binary. Auth, routing, encryption*,
│ │ device registry, delivery state, rate limiting.
└────────┬───────────┘
│ encrypted envelope
▼
┌────────────────────┐
│ Push transport │ FCM today (data messages only); replaceable
│ (FCM) │ (docs/protocol.md §11).
└────────┬───────────┘
▼
┌────────────────────┐
│ Android app │ Kotlin/Compose. Receives envelope, decrypts
│ │ locally, posts an Android notification.
└────────────────────┘* Encryption happens on the server in Mode A, or on the sender in Mode B
(zero-knowledge). The server never holds decryption keys.
The server stores state in a dual-dialect store (server/internal/store):
SQLite (pure-Go driver, no CGO) for self-hosted, PostgreSQL for SaaS. It holds
the device registry, token hashes, accounts/sessions/teams, billing state, the
replay-keys set, and ephemeral delivery records.
Around the server: two React Router web apps (prelay-dashboard/ for Cloud
customers, prelay-landing/ for marketing), a Go operator panel
(prelay-admin/), the Android app, and a monitoring/ Prometheus/Grafana/
Alertmanager stack.
Data flows
Device registration
- The app generates an X25519 keypair; the private key is sealed with an Android Keystore AES-GCM key and never leaves the device.
- The app POSTs its name, public key, and FCM token to
/v1/devices. - The server stores the public key and computes a fingerprint.
Sending (Mode A — server-assisted, self-hosted default)
- Sender posts plaintext to
/v1/notify. - The server looks up each target device's public key.
- For each device it generates an ephemeral sender key, derives a shared secret (X25519), and encrypts the plaintext with ChaCha20-Poly1305.
- The envelope goes to FCM as a data message (
{"env": …}); the server records delivery state.
Sending (Mode B — sender-encrypted, zero-knowledge)
- The sender fetches the device public key + key version.
- The sender encrypts locally (same envelope format) and posts it with
encrypted: true. - The server validates (version, clock skew, key version, replay) and routes — it cannot read the contents. This is the only mode the SaaS accepts.
Receiving (Android)
- FCM delivers a data message to the app's
FirebaseMessagingService. - The app parses the envelope, derives the shared secret from its private key and the ephemeral sender key, and decrypts.
- It maps the plaintext to an Android notification (channel, priority) and posts it. Anything that fails to authenticate is discarded.
Security model
See PRD §6 for the full threat model. The design assumes the network, push transport, server DB/filesystem/backups, and even a malicious admin may be compromised; the goal is to minimize what each component can obtain.
| Component compromised | Attacker can see | Cannot see |
|---|---|---|
| Network / FCM | ciphertext, routing metadata | plaintext (E2EE) |
| Server DB + filesystem | device records, token hashes, routing | plaintext, private keys |
| Server admin (Mode A) | plaintext (server-assisted) | — (documented trade-off) |
| Server admin (Mode B) | ciphertext only | plaintext (zero-knowledge) |
| Stolen device | — (key is Keystore-sealed) | — (revocable) |
Data minimization is enforced in code: notification plaintext is never
persisted, tokens are stored only as SHA-256 hashes, and delivery records
expire via the janitor (PRELAY_DELIVERY_TTL).
Self-notification guarantee
Prelay can only deliver to devices that registered to the sender's own account. Notification routing resolves targets inside the authenticated account's device registry; Mode B encryption additionally requires that account's device public key, which is tenant-scoped. There is no cross-tenant recipient path — a customer provably cannot message anyone else (PRD §6.2).
Transport abstraction
Delivery is behind a small interface so the core never cares which transport is used:
type Transport interface {
Push(ctx context.Context, device Device, env *crypto.Envelope) error
}FCM is the first driver; UnifiedPush and self-hosted transports can be added without protocol changes (PRD §29).
Repository layout
server/ Go server: cmd/{prelayd,prelay} + internal/{api,auth,billing,config,crypto,mailer,metrics,push,store}
android/ Kotlin/Compose app: io.prelay.android
prelay-dashboard/ React Router dashboard (Prelay Cloud)
prelay-landing/ React Router marketing site
prelay-admin/ Go single-binary SaaS admin panel (same Postgres)
monitoring/ Prometheus, Alertmanager, Grafana dashboards
brand/ Brand system: brand_guide.md, logo/favicon assets
docs/ protocol, api, deployment, cli, database, architecture, PRD
scripts/ dev.sh, e2e.sh, setup-android.sh, android-env.sh, send-test.sh, release-{android,server}.sh, saas-docker.sh
docker-compose.yml single-container deployment
docker-compose.saas.yml local SaaS dev stack (Postgres + Stripe CLI)