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

  1. The app generates an X25519 keypair; the private key is sealed with an Android Keystore AES-GCM key and never leaves the device.
  2. The app POSTs its name, public key, and FCM token to /v1/devices.
  3. The server stores the public key and computes a fingerprint.

Sending (Mode A — server-assisted, self-hosted default)

  1. Sender posts plaintext to /v1/notify.
  2. The server looks up each target device's public key.
  3. For each device it generates an ephemeral sender key, derives a shared secret (X25519), and encrypts the plaintext with ChaCha20-Poly1305.
  4. The envelope goes to FCM as a data message ({"env": …}); the server records delivery state.

Sending (Mode B — sender-encrypted, zero-knowledge)

  1. The sender fetches the device public key + key version.
  2. The sender encrypts locally (same envelope format) and posts it with encrypted: true.
  3. 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)

  1. FCM delivers a data message to the app's FirebaseMessagingService.
  2. The app parses the envelope, derives the shared secret from its private key and the ephemeral sender key, and decrypts.
  3. 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 compromisedAttacker can seeCannot see
Network / FCMciphertext, routing metadataplaintext (E2EE)
Server DB + filesystemdevice records, token hashes, routingplaintext, private keys
Server admin (Mode A)plaintext (server-assisted)— (documented trade-off)
Server admin (Mode B)ciphertext onlyplaintext (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)

On this page