Architecture
System Overview
┌──────────────────┐ ┌────────────────────────────────────────┐ ┌──────────────────────┐
│ Admin Panel │────▶│ visionpush-api │────▶│ visionpush-storage │
│ (Angular 18) │ │ Axum 0.8 · OAuth 2.1 · OpenAPI 3.1 │ │ Filesystem │ S3 │
└──────────────────┘ └──────────────┬─────────────────────────┘ └──────────────────────┘
│ NATS JetStream publish
┌─────────▼──────────┐
│ NATS JetStream │
│ PUSH_CAMPAIGNS │
│ PUSH_BATCHES.* │
│ PUSH_FEEDBACK │
└─────────┬──────────┘
│ consume
┌─────────▼──────────────────────────────┐
│ visionpush-worker │
│ campaign_dispatcher · batch_ios │
│ batch_android · batch_web │
│ scheduled_send · feedback · webhook │
│ health :9090/healthz │
└─────────┬──────────────────────────────┘
│
┌─────────────────────┼──────────────────────┐
▼ ▼ ▼
Apple APNS Google FCM v1 Web Push (VAPID)
(HTTP/2) (HTTP/1.1) (RFC 8030 + ECE)
Crates
visionpush-api
The public-facing REST API server. Built on Axum 0.8 with Tokio. Responsibilities:
- REST API (
/v1/*) — device registration, campaign creation, push dispatch, analytics queries, media upload, webhook management, in-app messages, notification channels - OAuth 2.1 / OIDC 1.0 — embedded auth server (PKCE, refresh tokens, password reset, team invites) via
visionpush-auth - Campaign dispatch — validates and publishes campaign batches to NATS
PUSH_CAMPAIGNSstream - Analytics queries — queries
visionpush-analyticsfor funnel and timeline data - Prometheus metrics — exposes
/metricsendpoint - Media storage — delegates to
visionpush-storage
Key files:
src/
main.rs — startup: DB, NATS, storage, auth, routes, graceful shutdown
config.rs — Config struct built from environment variables
state.rs — AppState shared across all handlers
routes/ — one file per feature domain
middleware/ — CORS, auth, rate limiting, request ID
db/nats.rs — JetStream stream bootstrap
visionpush-worker
A background process that consumes NATS streams and delivers push notifications. Runs entirely headless — no HTTP server except a /healthz health probe on :9090.
Processors (each runs as an independent Tokio task via tokio::join!):
| Processor | Stream | Consumer |
|---|---|---|
campaign_dispatcher |
PUSH_CAMPAIGNS |
campaign-dispatcher-consumer |
batch_ios |
PUSH_BATCHES |
ios-batch-consumer |
batch_android |
PUSH_BATCHES |
android-batch-consumer |
batch_web |
PUSH_BATCHES |
web-batch-consumer |
scheduled_send |
— | 30-second poll loop |
feedback |
PUSH_FEEDBACK |
— |
webhook |
PUSH_FEEDBACK |
— |
Resilience: per-request timeouts (10 s APNs/FCM, 15 s Web Push), AIMD exponential backoff with full jitter on 429/throttle responses, semaphore-based backpressure, permit release before sleep so the rest of the batch keeps moving.
Analytics: writes PushEventRecord to visionpush-analytics writer; periodic flush every 5 seconds; nightly compaction at 03:00 UTC.
visionpush-core
Platform push clients with no external state. Each client is created once at startup and pooled.
| Module | Protocol | Notes |
|---|---|---|
apns |
HTTP/2 (TLS) | p8 key auth, supports all interruption levels |
fcm |
HTTP/1.1 | FCM v1 API, service account JWT |
vapid |
HTTPS | RFC 8030 + ECE (AES-128-GCM content encryption) |
Key types:
PushMessage— complete campaign message with localized content, options, targetsPushTarget— device token + platform + language + environmentPushResult/DeliveryStatus—Sent | Delivered | Failed | Bounced | Throttled | ExpiredPlatformCredentials— APNs p8 key, FCM service account JSON, VAPID keypair
visionpush-analytics
Zero-dependency analytics engine using Apache Arrow, Parquet, and DataFusion. No external OLAP database required.
Storage layout:
{data_dir}/
events/
date=2026-05-19/
events-{timestamp}.parquet ← per-flush file (ZSTD compressed)
agg/
daily/
app=<id>/date=2026-05-19/stats.parquet ← pre-aggregated daily stats
Write path: PushEventWriter buffers events in memory, flushes to Parquet when the threshold is reached (or on timer). Thread-safe via Arc<Mutex<Vec<PushEventRecord>>>.
Read path: PushEventReader uses a DataFusion SessionContext that scans both events/ (live) and agg/ (pre-aggregated) directories. Context is rebuilt when the generation counter advances (write-read coordination without locks).
Nightly compaction (03:00 UTC): merges per-flush Parquet files into daily aggregated files and updates pre-aggregations. Reduces query scan time as data ages.
visionpush-auth
A standalone OAuth 2.1 / OIDC 1.0 server embedded inside the API process.
Endpoints:
GET /.well-known/openid-configuration — OIDC discovery
GET /oauth/authorize — authorization endpoint (PKCE)
POST /oauth/token — token exchange
GET /oauth/userinfo — user info
POST /auth/password/change — password change
POST /auth/reset/request — password reset request (sends email)
POST /auth/reset/confirm — password reset confirmation
POST /auth/invite/accept — team invite acceptance
Tokens: signed HS256 JWTs (JWT_SECRET). Passwords: Argon2id. Sessions: refresh token rotation with family tracking (detect theft). PKCE: S256 only.
visionpush-storage
Content-addressed blob storage with two backends behind a single BlobStorage trait.
Content addressing: every blob is identified by its BLAKE3 hash. Identical files are stored once (deduplication). Reference counting enables safe garbage collection.
Storage layout (both backends):
{prefix}/ab/cd/{blake3-hash}.{ext} ← content blob
{prefix}/ab/cd/{blake3-hash}.meta.json ← metadata sidecar
| Backend | Use case | Feature flag |
|---|---|---|
FileSystemBlobStorage |
Local dev, single-server | (always available) |
S3BlobStorage |
Production (AWS S3, Cloudflare R2, MinIO, Backblaze B2) | features = ["s3"] |
S3 backend uses manual AWS SigV4 signing (sigv4.rs::Signer) built on sha2 + hmac — no AWS SDK, zero extra dependencies.
Accepted content types: image/jpeg, image/png, image/gif, image/webp. Max size: 10 MiB.
Data Flow
Campaign Dispatch
-
POST /v1/push/send
API validates the request, resolves audience (all / segment / external IDs), and publishes aCampaignMessageto NATSPUSH_CAMPAIGNS. -
campaign_dispatcher (worker)
ReadsPUSH_CAMPAIGNS, splits the audience into batches ofBATCH_SIZEdevices, and publishesPushBatchtoPUSH_BATCHES.{ios|android|web}. -
batch_{ios|android|web} (worker)
ReadsPUSH_BATCHES.{platform}, deserializes the batch, calls thevisionpush-coreplatform client per device. On success: publishesPushEventtoPUSH_FEEDBACK. On throttle: AIMD backoff, NAK message for redelivery. -
feedback processor (worker)
ReadsPUSH_FEEDBACK, writesPushEventRecordtovisionpush-analytics, updates the device table (bounced tokens, last_seen). -
webhook processor (worker)
ReadsPUSH_FEEDBACK, calls registered webhook URLs for subscribed events.
Analytics Query
GET /v1/analytics/campaigns/{id}/funnel
API → PushEventReader.campaign_funnel(org_id, campaign_id)
→ DataFusion SQL scan over events/ + agg/ directories
→ returns Vec<FunnelRow> {event_type, platform, count}
NATS Streams
| Stream | Subjects | Purpose |
|---|---|---|
PUSH_CAMPAIGNS |
push.campaign |
Campaign dispatch jobs |
PUSH_BATCHES |
push.batch.ios, push.batch.android, push.batch.web |
Device batches per platform |
PUSH_FEEDBACK |
push.feedback |
Delivery results |
All streams use file storage with limits retention. JetStream guarantees at-least-once delivery; idempotency is ensured by the push_events table unique constraint on (campaign_id, device_id, event_type).
Database Tables
| Table | Description |
|---|---|
organizations | Top-level tenants |
team_members | Users with RBAC roles (owner, admin, developer, viewer) |
apps | Push apps (one per mobile/web app) |
platform_configs | APNs p8 keys, FCM service accounts, VAPID keypairs (AES-GCM encrypted) |
api_keys | Scoped API keys (vp_live_…) |
devices | Push tokens, platform, last_seen, frequency cap |
segments | Audience segments with filter rules |
campaigns | Campaigns with A/B variants, scheduling, status |
push_events | Per-event delivery funnel, mirrored to Parquet |
notification_channels | Android notification channel config per app |
in_app_messages | In-app message templates |
webhooks | Webhook endpoints per app |
webhook_deliveries | Webhook delivery log with retry state |
usage_counters | Monthly push volume per org |
password_reset_tokens | Time-limited tokens for password reset |
Migrations are managed by sqlx-migrate, run automatically on API startup and via the pre-deploy Helm hook Job.
Multi-tenancy
Every database row carries org_id. The API middleware extracts the JWT subject or API key, resolves the org_id, and injects it into AppState for every request. Cross-tenant access is structurally impossible — all queries include WHERE org_id = $1.
RBAC roles in ascending privilege order:
| Role | Can do |
|---|---|
viewer |
Read campaigns, analytics |
developer |
+ Manage apps, devices, API keys |
admin |
+ Manage team members, webhooks |
owner |
Full access including billing and org deletion |