228 lines
10 KiB
Markdown
228 lines
10 KiB
Markdown
# Architecture
|
||
|
||
Status: target architecture for the first working release.
|
||
|
||
## System shape
|
||
|
||
Who Need Help is a modular Phoenix application rather than a collection of
|
||
premature microservices. It produces one immutable image with four runtime
|
||
roles:
|
||
|
||
- `web`: Phoenix Endpoint, LiveView, PubSub, Presence, and a producerless Oban
|
||
client for atomic job insertion.
|
||
- `worker`: Oban queues and scheduled jobs; no public HTTP listener.
|
||
- `combined`: Phoenix Endpoint and the full Oban queues in one BEAM VM for a
|
||
lower-overhead single-server deployment.
|
||
- `migrate`: a one-shot database migration command before rollout.
|
||
|
||
PostgreSQL with PostGIS is the system of record. User-visible writes are
|
||
committed to PostgreSQL before a PubSub notification is broadcast. This makes
|
||
realtime delivery recoverable: after a reconnect, the client reads authoritative
|
||
state from the database.
|
||
|
||
```text
|
||
browser/PWA or Android WebView
|
||
│
|
||
▼
|
||
Traefik / Kubernetes Service
|
||
│
|
||
├── web replica A ─┐
|
||
└── web replica B ─┼── PostgreSQL + PostGIS
|
||
│
|
||
worker A/B ─────┤
|
||
└── Oban jobs
|
||
|
||
web and worker nodes form a BEAM cluster for distributed PubSub/Presence
|
||
```
|
||
|
||
The Android module is a thin same-origin WebView shell with one native
|
||
foreground location service. Its debug origin is supplied at build time from
|
||
the repository's ignored `.env`; release builds require an explicit HTTPS
|
||
origin. Authentication cookies, LiveView WebSockets, MapLibre, and private chat
|
||
use the same Phoenix application as the browser. An origin-restricted
|
||
`WebViewCompat` message listener accepts tracking commands only from the trusted
|
||
main frame and starts the native service only from the visible Activity; the
|
||
service posts the current point through CSRF-protected,
|
||
participant-authorized same-origin routes and shows a persistent notification
|
||
with Stop. Production signing and distribution are separate operational work
|
||
and are not represented as complete.
|
||
|
||
## Application boundaries
|
||
|
||
- `Accounts`: users, email/password authentication, private authentication
|
||
identities, public social identities, privacy preferences, blocks, and
|
||
roles.
|
||
- `GoogleAuth`: optional Google OpenID Connect boundary for registration,
|
||
sign-in, and account linking. It uses state, nonce, and PKCE, returns only a
|
||
normalized verified-email identity, and does not expose provider tokens to
|
||
controllers or persistence.
|
||
- `SocialOAuth`: optional provider boundary for verified social linking. The
|
||
GitHub adapter uses OAuth state and PKCE, returns only normalized identity
|
||
attributes to the controller, and does not expose the provider token to the
|
||
persistence layer.
|
||
- `Catalog`: category tree, proposals, votes, moderation decisions, and
|
||
translated labels.
|
||
- `Help`: requests, assignments, state transitions, handover codes, and
|
||
completion evidence.
|
||
- `Activities`: social plans, organizer-approved participants, private group
|
||
messages, capacity-safe joins, and participant-scoped meeting coordinates.
|
||
- `Messaging`: match chat and delivery receipts.
|
||
- `Tracking`: consent-driven sharing sessions, ephemeral current positions,
|
||
and derived proximity signals.
|
||
- `Trust`: reviews, reports, blocks, leaderboard/reputation projections,
|
||
abuse signals, moderator audit events, and shared rate-limit policies.
|
||
- `Push`: privacy-safe product event construction, unique durable Oban jobs,
|
||
and a provider-neutral delivery adapter. Current events cover request
|
||
acceptance and new matched-chat messages.
|
||
|
||
Contexts normally call each other through public functions. A small number of
|
||
documented trust-and-safety transactions update related schemas together when
|
||
atomic cleanup or moderation requires it; those operations stay in context
|
||
functions rather than controllers or LiveViews.
|
||
|
||
## Realtime and clustering
|
||
|
||
Phoenix PubSub and Presence handle transient fan-out. Phoenix's generated
|
||
`dns_cluster` dependency discovers nodes using DNS polling:
|
||
|
||
- Compose nodes discover the `web` service on the shared internal network;
|
||
the internal-only `cluster-web` alias and `CLUSTER_INTERFACE=eth0` keep node
|
||
names on that network even when the container also has ingress or egress
|
||
interfaces. Workers join through those nodes and distributed Erlang forms
|
||
the full mesh.
|
||
- Kubernetes uses a headless service.
|
||
|
||
No sticky session is required for authenticated requests. Session cookies are
|
||
signed by a shared secret. Uploads, if later introduced, must go to shared
|
||
object storage rather than a container filesystem.
|
||
|
||
Web and migrate application processes start a producerless Oban client with no
|
||
queues, plugins, or peer leadership so transactions can insert unique jobs.
|
||
The worker and combined roles start queue consumers and scheduled-job plugins.
|
||
PostgreSQL coordinates queues and leadership, so no Redis dependency is
|
||
introduced. The worker runs only the queues used by product code:
|
||
`maintenance` for expiry/probes and `push` for provider-neutral delivery.
|
||
Their per-worker concurrency is configured independently; no unused default
|
||
queue is started.
|
||
|
||
## Geospatial data
|
||
|
||
Locations are stored as WGS84 coordinates through the PostGIS Ecto extension.
|
||
Current movement and proximity evidence is derived in the application from two
|
||
current points and their client-reported accuracy envelopes; no route is
|
||
retained. Browser updates stop with the page. Android updates may continue
|
||
while minimized only through the user-started foreground service and its
|
||
visible notification; the app does not request `ACCESS_BACKGROUND_LOCATION`.
|
||
MapLibre GL JS renders the map through a small JavaScript hook. The tile/style
|
||
URL and attribution are configuration, not hard-coded provider assumptions.
|
||
Production operators must use a tile service whose policy and capacity fit the
|
||
traffic; the public OpenStreetMap tile service is best-effort and has a usage
|
||
policy, not an application backend.
|
||
|
||
## Data durability and retention
|
||
|
||
- Requests, activities, membership decisions, messages, reviews, reports, and
|
||
audit events are durable database records.
|
||
- A tracking session stores only its current position while active, never a
|
||
route history.
|
||
- Stopping sharing, completing/cancelling a match, or blocking the counterpart
|
||
deletes exact position records. Derived sample count, uncertainty-adjusted
|
||
distance, movement timestamp, and proximity timestamp remain.
|
||
- Secrets, exact coordinates, and private chat are excluded from local Codex
|
||
moderation inputs.
|
||
|
||
## Deployment
|
||
|
||
### Docker Compose
|
||
|
||
The Compose launcher selects two independent dimensions from the ignored
|
||
environment:
|
||
|
||
- `APP_TOPOLOGY=split` starts Traefik plus independently scalable web and
|
||
worker replicas. Normal development defaults to 2 web and 2 worker replicas.
|
||
- `APP_TOPOLOGY=compact` starts one combined Phoenix+Oban VM and publishes its
|
||
port directly to the host reverse proxy.
|
||
- `DATABASE_MODE=container` starts one project-owned PostGIS container.
|
||
- `DATABASE_MODE=external` removes the PostGIS service from the active Compose
|
||
model and connects migrate/application roles through `DATABASE_URL`.
|
||
|
||
The normal split development combination starts:
|
||
|
||
- Traefik
|
||
- 2 × web
|
||
- 2 × worker
|
||
- 1 × PostGIS
|
||
- 1 × Mailpit
|
||
|
||
Compose services have no fixed container names, enabling replicas. The project
|
||
uses an isolated Compose project name and network. PostgreSQL is intentionally
|
||
single-instance in the local profile; multiple uncoordinated containers would
|
||
not create database high availability.
|
||
|
||
Kubernetes is never started by Docker Compose. `DEPLOYMENT_TARGET=kubernetes`
|
||
is handled explicitly by `scripts/deploy-up.sh`; `KUBERNETES_MODE=kind` is the
|
||
local verification path and `KUBERNETES_MODE=helm` is the external-cluster
|
||
path.
|
||
|
||
### Kubernetes
|
||
|
||
The Helm chart contains separate web and worker Deployments, Services, a
|
||
headless cluster-discovery Service, a migration Job, Secret interfaces, probes,
|
||
disruption-aware rolling updates, and an ingress NetworkPolicy. The policy
|
||
allows unrestricted Erlang distribution only between pods belonging to the
|
||
same chart instance, exposes the configured HTTP listener, and isolates other
|
||
inbound pod ports. Kubernetes enforces this only when the cluster's network
|
||
plugin implements NetworkPolicy. It does not restrict egress because the
|
||
production database, SMTP, OAuth, map, and push-provider destinations are not
|
||
known yet; those rules must be added from verified deployment-specific
|
||
addresses rather than invented in the chart. The chart does not invent
|
||
CPU/memory limits or an HPA threshold before measurements exist.
|
||
|
||
The bundled kind path is for reproducible local verification. A production
|
||
database should be an independently operated PostgreSQL/PostGIS service with
|
||
backups and a tested recovery procedure.
|
||
|
||
## Shared abuse counters
|
||
|
||
Action buckets live in PostgreSQL and use an atomic upsert keyed by action,
|
||
hashed scope, and aligned time window. This works across all web replicas
|
||
without an in-memory or Redis singleton. Policies are supplied through
|
||
`RATE_LIMIT_POLICIES_JSON`; no numeric product policy is compiled into the
|
||
application. Expired buckets are pruned by the maintenance worker.
|
||
|
||
The Compose and kind scripts finish by subscribing on one live BEAM node,
|
||
broadcasting through a different connected node, and failing if the PubSub
|
||
probe is not received.
|
||
|
||
## AI boundary
|
||
|
||
AI is not in the critical request or safety path. An administrator can export a
|
||
redacted batch of category proposals to the locally installed Codex CLI:
|
||
|
||
```text
|
||
codex exec --ephemeral --sandbox read-only --output-schema …
|
||
```
|
||
|
||
The process uses the user's ChatGPT-authenticated local Codex session. It never
|
||
uses an OpenAI API key, usage-based API billing, or an API fallback. The model
|
||
may group duplicates and draft English, Ukrainian, and Russian labels; a human
|
||
moderator applies every change.
|
||
|
||
## Versioning policy
|
||
|
||
Exact image and package versions are pinned in the repository after successful
|
||
build verification. Renovation is a deliberate change accompanied by tests,
|
||
not an implicit `latest` pull.
|
||
|
||
## Primary references
|
||
|
||
- [Phoenix 1.8 documentation](https://hexdocs.pm/phoenix/overview.html)
|
||
- [Phoenix PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html)
|
||
- [Phoenix Presence](https://hexdocs.pm/phoenix/presence.html)
|
||
- [Oban](https://hexdocs.pm/oban/Oban.html)
|
||
- [DNSCluster](https://hexdocs.pm/dns_cluster/DNSCluster.html)
|
||
- [PostGIS](https://postgis.net/documentation/)
|
||
- [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/)
|
||
- [OpenStreetMap tile usage policy](https://operations.osmfoundation.org/policies/tiles/)
|
||
- [W3C Geolocation](https://www.w3.org/TR/geolocation/)
|