Skip to content

Commit 0ccf9bc

Browse files
committed
✨(mobile) add self-hosted OTA update chain
Store review cycles make shipping web-layer fixes through the stores too slow, so the apps update their JS bundle over the air. The chain is fully self-hosted to keep sovereignty: bundles and channel manifests live on an anonymous-read S3 bucket (create_bucket --public / the create-ota-bucket script) and the Capgo plugin is driven entirely from JS against that manifest (autoUpdate off — no Capgo server involved). Bundles are RSA-signed at publish time and verified against the per-instance public key baked in at cap sync, so a tampered zip on the public bucket is rejected. Versions use a git-derived <count>-<sha> id stamped into the builtin bundle so a fresh install does not re-download its own commit, and channels (dev/staging/prod) are fully independent because NEXT_PUBLIC_* vars are inlined at build time. Also ships docs/mobile.md.
1 parent c6caf71 commit 0ccf9bc

24 files changed

Lines changed: 1725 additions & 6 deletions

File tree

Makefile

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,31 @@ shell-objectstorage: ## open a shell in the objectstorage container
185185
@$(COMPOSE) run --rm --build objectstorage bash
186186
.PHONY: shell-objectstorage
187187

188+
# Generate a per-instance OTA signing key pair. Prints the base64 PEMs to stdout:
189+
# MOBILE_OTA_SIGNING_PUBLIC_KEY_B64 (baked into the app) + MOBILE_OTA_SIGNING_PRIVATE_KEY_B64
190+
# (publish secret). Each deployment runs it once; the private half is a CI secret,
191+
# never committed. No object storage needed — pure key generation.
192+
mobile-ota-keygen: ## generate a per-instance OTA signing key pair (base64 PEMs)
193+
@$(COMPOSE_RUN) --no-deps frontend-mobile npm run --silent mobile:ota:keygen
194+
.PHONY: mobile-ota-keygen
195+
196+
mobile-ota-bucket: ## create the public mobile OTA bucket in objectstorage
197+
@$(COMPOSE) up -d objectstorage --wait
198+
@$(COMPOSE_RUN) frontend-mobile npm run mobile:ota:bucket
199+
.PHONY: mobile-ota-bucket
200+
201+
# Build the web bundle in the env-aware container (dist lands on the host via the
202+
# bind mount), then zip + upload it and the channel manifest to the public
203+
# bucket. Both steps run in the frontend toolchain: the OTA release is a
204+
# frontend artifact, Django is not involved. VERSION defaults to the git-derived
205+
# MOBILE_OTA_BUILD_ID (the hybrid <count>-<sha> id); override it to pin a specific
206+
# release. CHANNEL defaults to the MOBILE_OTA_CHANNEL env var (frontend env files).
207+
ota-publish: VERSION ?= $(MOBILE_OTA_BUILD_ID)
208+
ota-publish: ## build and publish a mobile OTA bundle (VERSION defaults to <count>-<sha>, CHANNEL to MOBILE_OTA_CHANNEL)
209+
@$(COMPOSE) up -d objectstorage --wait
210+
@$(COMPOSE_RUN) frontend-mobile sh -c "npm run build && npm run mobile:ota:publish -- --version $(VERSION)$(if $(CHANNEL), --channel $(CHANNEL))"
211+
.PHONY: ota-publish
212+
188213
# -- Linters
189214

190215
lint: ## run all linters
@@ -609,17 +634,25 @@ build-front: ## build the frontend locally
609634
@$(COMPOSE) run --rm --build frontend-tools npm run build
610635
.PHONY: build-front
611636

637+
# Hybrid OTA/build version: a monotonic commit count (for ordering — enables a
638+
# future downgrade check) plus the short SHA (for traceability). Computed on the
639+
# HOST (git is not in the container) and injected; CI may override it.
640+
MOBILE_OTA_BUILD_ID ?= $(shell git rev-list --count HEAD)-$(shell git rev-parse --short HEAD)
641+
612642
# Mobile (Capacitor). The web bundle is built in a container (frontend-mobile,
613643
# which carries the env_file so the NEXT_PUBLIC_* vars are inlined) and synced
614644
# into the native projects. The sync (not a bare copy) also regenerates the
615645
# gitignored capacitor-cordova-android-plugins/ scaffolding that Gradle needs,
616646
# so always run `make mobile-build` after a fresh checkout. The native compile /
617647
# IDE / device steps are macOS- and SDK-bound, so they stay on the host.
648+
# MOBILE_OTA_BUILD_ID is passed so `cap sync` stamps it as the builtin bundle version
649+
# (capacitor.config.ts), letting the OTA freshness check match a same-commit
650+
# manifest instead of re-downloading on first launch.
618651
#
619652
# Hot reload: MOBILE_DEV_SERVER_URL (frontend env files, set by default in dev)
620653
# is baked as the WebView's server.url at `cap sync` — see docs/mobile.md.
621654
mobile-build: ## build the web bundle and sync it + native plugins into the projects (container, env-aware)
622-
@$(COMPOSE) run --rm --build frontend-mobile npm run mobile:build
655+
@$(COMPOSE) run --rm --build -e MOBILE_OTA_BUILD_ID=$(MOBILE_OTA_BUILD_ID) frontend-mobile npm run mobile:build
623656
.PHONY: mobile-build
624657

625658
# Regenerate the native app icons and splashscreens from src/frontend/assets/
@@ -646,8 +679,8 @@ mobile-ios: mobile-build ## build the bundle (container) then open the iOS proje
646679
# here), so this Makefile is the single source of truth for the port list and
647680
# the gradle task.
648681
# Ports the in-app WebView reaches through the device→host adb tunnel:
649-
# 8900 dev frontend, 8901 backend, 8902 Keycloak.
650-
ANDROID_REVERSE_PORTS = 8900 8901 8902
682+
# 8900 dev frontend, 8901 backend, 8902 Keycloak, 8906 object storage (OTA).
683+
ANDROID_REVERSE_PORTS = 8900 8901 8902 8906
651684
ANDROID_DEBUG_APK = src/frontend/android/app/build/outputs/apk/debug/app-debug.apk
652685

653686
mobile-android-reverse: ## (host) map device ports to the dev stack via adb reverse

docs/env.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,42 @@ blob stays in PG.
176176
| `MESSAGES_BLOBS_ENCRYPT_KEYS` | `{}` | JSON dict mapping `key_id` → entry. Each entry must be `{"algo": "aes-gcm", "secret": "<32+ chars>", "active": <bool>}`. Add `"active": true` to exactly one entry to make it the key new blobs are encrypted with; entries without `active` (or with `active=false`) stay readable for legacy ciphertext. The secret is SHA-256'd to a 32-byte AEAD key, so its strength is whatever entropy the operator supplied — use `openssl rand -base64 32` (or equivalent). Startup emits a warning when a secret is shorter than 32 characters; that floor is a length check only, not an entropy measurement. | Optional |
177177
| `MESSAGES_BLOBS_VERIFY_HASH` | `False` | When True, `Blob.get_content()` re-hashes plaintext and rejects mismatches. One SHA-256 over the plaintext per read; main value is for `key_id=0` blobs (encrypted blobs are already AAD-bound). | Optional |
178178

179+
### Mobile OTA Bundles
180+
181+
Public (anonymous read) bucket holding the mobile OTA artifacts, one
182+
self-contained folder per release channel (`channels/<channel>/manifest.json` +
183+
`channels/<channel>/bundles/<version>.zip`). Created with `make mobile-ota-bucket`;
184+
bundles are published with `make ota-publish [CHANNEL=…]`. Channels are fully
185+
independent: `NEXT_PUBLIC_*` vars are inlined into the bundle at build time, so
186+
each channel ships its own build — never copy a bundle across channels.
187+
188+
Except for `MOBILE_OTA_MANIFEST_URL` (a **backend** setting served to the apps
189+
through the `/config` endpoint), these are **frontend-toolchain / publish-time**
190+
variables (read by `src/frontend/scripts/*-ota*.mjs`, not by Django). They live
191+
in the frontend env files; in CI they come from secrets.
192+
193+
> **Opt-in in dev**: they ship **commented out** in
194+
> `env.d/development/frontend.defaults` (the values below are the working
195+
> dev-stack ones) — uncomment them, along with `MOBILE_OTA_MANIFEST_URL` in
196+
> `backend.local`, to exercise the OTA chain locally.
197+
> Hot reload (`MOBILE_DEV_SERVER_URL`, on by default in dev) skips the startup
198+
> OTA check; disable it to test OTA end to end.
199+
200+
| Variable | Default | Description | Required |
201+
|----------|---------|-------------|----------|
202+
| `MOBILE_OTA_S3_ENDPOINT` | `http://objectstorage:9000` | S3 endpoint the script **writes** to (compose network in dev; target S3 in CI) | Optional |
203+
| `MOBILE_OTA_S3_BUCKET` | `messages-ota` | S3 bucket name for OTA bundles | Optional |
204+
| `MOBILE_OTA_S3_ACCESS_KEY` | `st-messages` | S3 access key | Optional |
205+
| `MOBILE_OTA_S3_SECRET_KEY` | `password` | S3 secret key | Optional |
206+
| `MOBILE_OTA_S3_REGION` | `us-east-1` | S3 region | Optional |
207+
| `MOBILE_OTA_S3_KEY_PREFIX` | `` (empty) | Object key prefix; empty for a dedicated bucket root, `messages/mobileapp/` for a shared bucket. Must stay consistent with `MOBILE_OTA_PUBLIC_BASE_URL` | Optional |
208+
| `MOBILE_OTA_CHANNEL` | `dev` (dev env) | Release channel `mobile:ota:publish` targets (`channels/<channel>/…`); overridable per run with `--channel` / `make ota-publish CHANNEL=…`. The deploy pipeline uses `staging` and `prod`, each publishing its own build. Must match the channel segment of the `MOBILE_OTA_MANIFEST_URL` served by the backend this deployment's apps talk to | Optional |
209+
| `MOBILE_OTA_MANIFEST_URL` | None (**backend** env) | OTA channel manifest URL the apps poll at startup, served through the `/config` endpoint — so the followed channel can change without shipping a new native build. Must point to the channel this deployment publishes to. Unset disables OTA | Optional |
210+
| `MOBILE_OTA_PUBLIC_BASE_URL` | `http://localhost:8906/messages-ota` | Device-reachable **read** base URL written into the manifest | Optional |
211+
| `MOBILE_OTA_SIGNING_PRIVATE_KEY_B64` | None | Base64-encoded (single-line) RSA private-key PEM signing/encrypting each bundle at publish time (`publish-ota.mjs`). CI secret only — never commit. Unset ⇒ publish fails. See [mobile.md](./mobile.md#generating-the-signing-key-pair) | Optional |
212+
| `MOBILE_OTA_SIGNING_PUBLIC_KEY_B64` | None | Base64-encoded (single-line) RSA public-key PEM baked into the app at `cap sync` time (`capacitor.config.ts`, native verification) **and** inlined by Vite into the JS bundle (`ota.ts` refuses a server-provided manifest URL on a key-less build). Read by the builds, not the publish scripts — an OTA-enabled build can never apply an unsigned bundle | Required if OTA enabled |
213+
| `MOBILE_OTA_BUILD_ID` | `<count>-<sha>` (git-derived) | Hybrid release id stamped into the builtin bundle version at `cap sync` (`capacitor.config.ts`) and used as the default OTA `VERSION`. Computed by the Makefile from git; override in CI to pin a build. See [mobile.md](./mobile.md#bundle-versioning) | Optional |
214+
179215
### Static Files
180216

181217
| Variable | Default | Description | Required |
@@ -242,6 +278,7 @@ _Those settings are deprecated and will be removed in the future._
242278
|----------|---------|-------------|----------|
243279
| `MOBILE_APP_ID` | `local.suitenumerique.messages` | Store/OS bundle identifier of the native app. The repo ships a neutral placeholder; an organisation publishing to the App Store / Play Store overrides it with its own signed id. Read by `cap sync` (container) **and** the native builds — gradle `applicationId`, iOS `PRODUCT_BUNDLE_IDENTIFIER` — so it must be exported in **both** the container env and the host/CI env. Independent of the auth callback scheme (`stmessages`). | Optional |
244280
| `MOBILE_DEV_SERVER_URL` | `http://localhost:8900` (dev env, `frontend.defaults`) | **Dev only.** URL of the Vite dev server baked as Capacitor `server.url` at `cap sync` (`capacitor.config.ts`): the WebView then loads the app from Vite with hot reload instead of the embedded bundle. To disable (embedded bundle / OTA testing), set it **empty** in `frontend.local` and rerun `make mobile-build`. Must never be set for a release build — a gradle guard fails Android release builds carrying it. See [mobile.md](./mobile.md#hot-reload-on-by-default-in-dev) | Optional |
281+
| `MOBILE_ALLOW_CLEARTEXT_FOR_DEV` | `1` (dev env, `frontend.defaults`) | **Dev only.** Baked as Capacitor `server.cleartext` at `cap sync` (`capacitor.config.ts`), i.e. `android:usesCleartextTraffic` in the Android manifest: allows plain HTTP for the whole app — the WebView reaching the Vite dev server and the native fetch/OTA layer reaching the `http://localhost:8901` backend and the RustFS OTA bucket (needed even with hot reload disabled). Must never be set for a release build — the manifest then stays cleartext-free. iOS equivalent: `NSAllowsLocalNetworking` (`Info.plist`, manual). | Optional |
245282

246283
> **Note**: overriding `MOBILE_APP_ID` only changes the app identity; it does **not** touch the OIDC deep-link scheme (`stmessages`), which is fixed and declared in the iOS `Info.plist` (`CFBundleURLTypes`) and the Android manifest.
247284
@@ -339,6 +376,7 @@ The following build-time variables are **deprecated**: they only act as fallback
339376
| `NEXT_PUBLIC_LAGAUFRE_WIDGET_PATH` | `FRONTEND_LAGAUFRE_WIDGET_CONFIG` (`path` key) |
340377
| `NEXT_PUBLIC_SENTRY_DSN` | `SENTRY_DSN` |
341378
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | `ENVIRONMENT` (backend environment) |
379+
| `NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL` | `MOBILE_OTA_MANIFEST_URL` |
342380

343381
## Development Tools
344382

0 commit comments

Comments
 (0)