(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.
This commit is contained in:
jbpenrath
2026-07-22 11:21:05 +02:00
parent ace7991084
commit 94d079bc1b
24 changed files with 1725 additions and 6 deletions
+36 -3
View File
@@ -185,6 +185,31 @@ shell-objectstorage: ## open a shell in the objectstorage container
@$(COMPOSE) run --rm --build objectstorage bash
.PHONY: shell-objectstorage
# Generate a per-instance OTA signing key pair. Prints the base64 PEMs to stdout:
# MOBILE_OTA_SIGNING_PUBLIC_KEY_B64 (baked into the app) + MOBILE_OTA_SIGNING_PRIVATE_KEY_B64
# (publish secret). Each deployment runs it once; the private half is a CI secret,
# never committed. No object storage needed — pure key generation.
mobile-ota-keygen: ## generate a per-instance OTA signing key pair (base64 PEMs)
@$(COMPOSE_RUN) --no-deps frontend-mobile npm run --silent mobile:ota:keygen
.PHONY: mobile-ota-keygen
mobile-ota-bucket: ## create the public mobile OTA bucket in objectstorage
@$(COMPOSE) up -d objectstorage --wait
@$(COMPOSE_RUN) frontend-mobile npm run mobile:ota:bucket
.PHONY: mobile-ota-bucket
# Build the web bundle in the env-aware container (dist lands on the host via the
# bind mount), then zip + upload it and the channel manifest to the public
# bucket. Both steps run in the frontend toolchain: the OTA release is a
# frontend artifact, Django is not involved. VERSION defaults to the git-derived
# MOBILE_OTA_BUILD_ID (the hybrid <count>-<sha> id); override it to pin a specific
# release. CHANNEL defaults to the MOBILE_OTA_CHANNEL env var (frontend env files).
ota-publish: VERSION ?= $(MOBILE_OTA_BUILD_ID)
ota-publish: ## build and publish a mobile OTA bundle (VERSION defaults to <count>-<sha>, CHANNEL to MOBILE_OTA_CHANNEL)
@$(COMPOSE) up -d objectstorage --wait
@$(COMPOSE_RUN) frontend-mobile sh -c "npm run build && npm run mobile:ota:publish -- --version $(VERSION)$(if $(CHANNEL), --channel $(CHANNEL))"
.PHONY: ota-publish
# -- Linters
lint: ## run all linters
@@ -609,17 +634,25 @@ build-front: ## build the frontend locally
@$(COMPOSE) run --rm --build frontend-tools npm run build
.PHONY: build-front
# Hybrid OTA/build version: a monotonic commit count (for ordering — enables a
# future downgrade check) plus the short SHA (for traceability). Computed on the
# HOST (git is not in the container) and injected; CI may override it.
MOBILE_OTA_BUILD_ID ?= $(shell git rev-list --count HEAD)-$(shell git rev-parse --short HEAD)
# Mobile (Capacitor). The web bundle is built in a container (frontend-mobile,
# which carries the env_file so the NEXT_PUBLIC_* vars are inlined) and synced
# into the native projects. The sync (not a bare copy) also regenerates the
# gitignored capacitor-cordova-android-plugins/ scaffolding that Gradle needs,
# so always run `make mobile-build` after a fresh checkout. The native compile /
# IDE / device steps are macOS- and SDK-bound, so they stay on the host.
# MOBILE_OTA_BUILD_ID is passed so `cap sync` stamps it as the builtin bundle version
# (capacitor.config.ts), letting the OTA freshness check match a same-commit
# manifest instead of re-downloading on first launch.
#
# Hot reload: MOBILE_DEV_SERVER_URL (frontend env files, set by default in dev)
# is baked as the WebView's server.url at `cap sync` — see docs/mobile.md.
mobile-build: ## build the web bundle and sync it + native plugins into the projects (container, env-aware)
@$(COMPOSE) run --rm --build frontend-mobile npm run mobile:build
@$(COMPOSE) run --rm --build -e MOBILE_OTA_BUILD_ID=$(MOBILE_OTA_BUILD_ID) frontend-mobile npm run mobile:build
.PHONY: mobile-build
# 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
# here), so this Makefile is the single source of truth for the port list and
# the gradle task.
# Ports the in-app WebView reaches through the device→host adb tunnel:
# 8900 dev frontend, 8901 backend, 8902 Keycloak.
ANDROID_REVERSE_PORTS = 8900 8901 8902
# 8900 dev frontend, 8901 backend, 8902 Keycloak, 8906 object storage (OTA).
ANDROID_REVERSE_PORTS = 8900 8901 8902 8906
ANDROID_DEBUG_APK = src/frontend/android/app/build/outputs/apk/debug/app-debug.apk
mobile-android-reverse: ## (host) map device ports to the dev stack via adb reverse
+38
View File
@@ -176,6 +176,42 @@ blob stays in PG.
| `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 |
| `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 |
### Mobile OTA Bundles
Public (anonymous read) bucket holding the mobile OTA artifacts, one
self-contained folder per release channel (`channels/<channel>/manifest.json` +
`channels/<channel>/bundles/<version>.zip`). Created with `make mobile-ota-bucket`;
bundles are published with `make ota-publish [CHANNEL=…]`. Channels are fully
independent: `NEXT_PUBLIC_*` vars are inlined into the bundle at build time, so
each channel ships its own build — never copy a bundle across channels.
Except for `MOBILE_OTA_MANIFEST_URL` (a **backend** setting served to the apps
through the `/config` endpoint), these are **frontend-toolchain / publish-time**
variables (read by `src/frontend/scripts/*-ota*.mjs`, not by Django). They live
in the frontend env files; in CI they come from secrets.
> **Opt-in in dev**: they ship **commented out** in
> `env.d/development/frontend.defaults` (the values below are the working
> dev-stack ones) — uncomment them, along with `MOBILE_OTA_MANIFEST_URL` in
> `backend.local`, to exercise the OTA chain locally.
> Hot reload (`MOBILE_DEV_SERVER_URL`, on by default in dev) skips the startup
> OTA check; disable it to test OTA end to end.
| Variable | Default | Description | Required |
|----------|---------|-------------|----------|
| `MOBILE_OTA_S3_ENDPOINT` | `http://objectstorage:9000` | S3 endpoint the script **writes** to (compose network in dev; target S3 in CI) | Optional |
| `MOBILE_OTA_S3_BUCKET` | `messages-ota` | S3 bucket name for OTA bundles | Optional |
| `MOBILE_OTA_S3_ACCESS_KEY` | `st-messages` | S3 access key | Optional |
| `MOBILE_OTA_S3_SECRET_KEY` | `password` | S3 secret key | Optional |
| `MOBILE_OTA_S3_REGION` | `us-east-1` | S3 region | Optional |
| `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 |
| `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 |
| `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 |
| `MOBILE_OTA_PUBLIC_BASE_URL` | `http://localhost:8906/messages-ota` | Device-reachable **read** base URL written into the manifest | Optional |
| `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 |
| `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 |
| `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 |
### Static Files
| Variable | Default | Description | Required |
@@ -242,6 +278,7 @@ _Those settings are deprecated and will be removed in the future._
|----------|---------|-------------|----------|
| `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 |
| `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 |
| `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 |
> **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.
@@ -339,6 +376,7 @@ The following build-time variables are **deprecated**: they only act as fallback
| `NEXT_PUBLIC_LAGAUFRE_WIDGET_PATH` | `FRONTEND_LAGAUFRE_WIDGET_CONFIG` (`path` key) |
| `NEXT_PUBLIC_SENTRY_DSN` | `SENTRY_DSN` |
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | `ENVIRONMENT` (backend environment) |
| `NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL` | `MOBILE_OTA_MANIFEST_URL` |
## Development Tools
+595
View File
@@ -0,0 +1,595 @@
# Mobile apps — technical architecture & onboarding
The Messages mobile apps (iOS + Android) are **the existing web frontend wrapped
in a [Capacitor](https://capacitorjs.com/) native shell**. There is no second
codebase: the same React/Vite bundle that serves `localhost:8900` runs inside a
`WKWebView` (iOS) / Android `WebView`, and a thin native layer supplies what a
browser cannot — a shared-cookie login, a native HTTP stack, file sharing and
over-the-air bundle updates.
This document is the onboarding reference for developers who need to work on the
apps: the architecture, where the code lives, and the prerequisites to build and
run on each platform.
> This is the production-facing companion to [`mobile-poc.md`](./mobile-poc.md),
> which records how the architecture was **validated** (smoke tests, cross-app
> SSO re-testing, negative controls). Read this file first; reach for the POC doc
> when you need the validation procedures.
## Why Capacitor (and not React Native)
The whole product value — rendering arbitrary email HTML safely — depends on an
`iframe` with `srcDoc` + `sandbox` + CSP. A previous React Native attempt broke
on exactly that. Capacitor keeps a real browser engine in the app, so the web
frontend renders identically to the desktop, and **one team maintains one UI**.
The cost is a set of WebView limitations the native layer must paper over
(session cookies, downloads, deep-link auth) — that layer is the interesting
part of this codebase and the rest of this doc.
## Architecture at a glance
```
┌─────────────────────────────────────────── native shell (iOS / Android) ──┐
│ │
│ ┌──────────────────────── WebView ───────────────────────┐ │
│ │ the web bundle (dist/) — React / TanStack / BlockNote │ │
│ │ │ │
│ │ window.fetch ──────────┐ (patched by CapacitorHttp) │ │
│ └─────────────────────────┼───────────────────────────────┘ │
│ ▼ │
│ ┌──────────────── native bridge (Capacitor plugins) ─────────────────┐ │
│ │ CapacitorHttp → native HTTP stack, native cookie jar │ │
│ │ WebAuthSession → ASWebAuthenticationSession (iOS, app-local) │ │
│ │ Browser → Chrome Custom Tabs (Android) │ │
│ │ Filesystem/Share→ downloads to OS share sheet │ │
│ │ CapacitorUpdater→ OTA bundle download / swap │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────┘
│ HTTPS (prod) / cleartext localhost (dev) │ system browser
▼ ▼
Django backend (confidential OIDC client) Identity provider
/api/v1.0/… ProConnect (prod) / Keycloak (dev)
```
Four concerns make the shell more than a browser:
1. **Networking & session**`window.fetch` is routed through the native HTTP
layer so cookies live in the native jar, not the WebView.
2. **Authentication** — the OIDC flow runs in the *system browser*, never the
WebView, which is what enables cross-app SSO across all La Suite apps.
3. **File I/O** — downloads and shares go through native plugins because an
`<a download>` escapes the WebView and loses the session.
4. **OTA updates** — the JS bundle can be replaced without a store release.
Each is detailed below.
## Authentication architecture
This is the load-bearing design decision. **The user who logs in on one La Suite
app (mail, calendar, …) must not re-enter credentials on the others.**
The OIDC flow runs in the **system browser**`ASWebAuthenticationSession` on
iOS, Chrome Custom Tabs on Android — following RFC 8252. The system browser
shares its cookie jar across apps, so the IdP session cookie (ProConnect in
production, Keycloak in development) provides the cross-app SSO: the second
app's login completes silently.
The backend stays the **confidential OIDC client** (`django-lasuite`). The IdP
only ever sees the ordinary web flow with the backend's HTTPS callback, so **no
IdP-side configuration is needed for mobile**. The Django session is then handed
to the app through a one-time token, bound to the app by PKCE:
```
App System browser Backend IdP
│ openAuthSession() │ │ │
│──────────────────────────────▶│ GET /api/v1.0/authenticate/ │ │
│ (+ mobile_scheme, │────────────────────────────▶│ 302 authorize │
│ code_challenge=S256) │─────────────────────────────┼───────────────────▶│
│ │ login form (or silent SSO redirect) │
│ │ GET /api/v1.0/callback/ │◀───────────────────│
│ stmessages://auth?token=… │◀────────────────────────────│ session + one-time │
│◀──────────────────────────────│ │ token (60 s TTL) │
│ POST /api/v1.0/mobile/auth/exchange/ {token, code_verifier}│ │
│────────────────────────────────────────────────────────────▶│ │
│◀── Set-Cookie sessionid + csrftoken, body {csrf_token} ─────│ │
```
Step by step:
1. **App starts the flow.** `nativeLogin()` generates a PKCE verifier
(`generateCodeVerifier`), computes its S256 challenge, and opens
`/api/v1.0/authenticate/?mobile_scheme=stmessages&code_challenge=…` in the
system browser.
2. **Backend flags the session.** `OIDCAuthenticationRequestView` checks the
scheme against `MOBILE_AUTH_CALLBACK_SCHEMES` (rejects unknown schemes) and
stashes `{scheme, code_challenge, created_at}` in the Django session. A flag
older than 10 min is later ignored, so an abandoned mobile attempt can't
hijack a subsequent web login in the same browser.
3. **IdP authenticates** — interactively the first time, silently afterwards
(see *Cross-app SSO conditions* below).
4. **Callback mints a one-time token.** `OIDCAuthenticationCallbackView` caches
`{session_key, code_challenge}` under `mobile-auth-token:<token>` with a
`MOBILE_AUTH_TOKEN_TTL` (60 s) timeout and deep-links back to
`stmessages://auth?token=…`.
5. **App exchanges the token.** `MobileSessionExchangeView` (anonymous, single
use) deletes the cache key *before* verifying it (a failed attempt can't be
retried), checks `S256(code_verifier) == code_challenge` with
`secrets.compare_digest`, rehydrates the session, and emits the
`Set-Cookie: sessionid` header plus a `csrf_token` in the body.
The token is a bearer secret for ~60 s; **PKCE is what makes a stolen deep link
useless** (the attacker lacks the verifier), which matters because custom URL
schemes can be claimed by other apps.
The `stmessages` return scheme must be registered natively on **both** platforms
(source of truth: `AUTH_CALLBACK_SCHEME` in `auth.ts`): an `intent-filter` in
`AndroidManifest.xml`, and `CFBundleURLTypes` in the iOS `Info.plist` — the
latter is required because `ASWebAuthenticationSession` runs with
`prefersEphemeralWebBrowserSession = false` (needed to share the IdP cookie), and
in that mode iOS only delivers the callback for an app-registered scheme. Both
are independent of `MOBILE_APP_ID`.
### Cross-app SSO conditions (both bit us during the POC)
- **Requested ACR must be satisfiable.** The backend sends
`OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}` (required by
ProConnect). The IdP only skips the form if its existing session already meets
that Level of Assurance. The dev Keycloak realm therefore **maps `eidas1`**
(`acr.loa.map` on the `messages` client in `src/keycloak/realm.json`); with an
empty map Keycloak forces re-authentication on every flow and silently breaks
cross-app SSO. Do not remove that mapping.
- **The IdP session cookie must be persistent on iOS.**
`ASWebAuthenticationSession` only shares Safari's *persistent* cookies; the
Keycloak identity cookie is a session cookie unless "Remember me" is ticked.
> **False positive to avoid:** visiting `localhost:8900` may "log in silently"
> simply because the Django session cookie is still valid — that never hits
> `/authorize` and does **not** prove IdP SSO. Always exercise the mobile flow.
### Logout keeps the IdP session alive
`nativeLogout()` does **not** call `/logout/`, which would trigger RP-initiated
IdP logout and tear down the cross-app SSO session. It POSTs to
`/api/v1.0/mobile/auth/logout/` instead, which flushes only the server-side
Django session, then clears the native cookies and the cached CSRF token.
IdP-level logout is a follow-up.
## Networking & session
`CapacitorHttp` (enabled in `capacitor.config.ts`) patches `window.fetch` so
every API call goes through the **native HTTP stack**, and the session cookies
live in the **native cookie jar**:
- No `SameSite` / ITP restriction, no WebView CORS.
- The plain-HTTP dev backend works (`server.cleartext` on Android — gated by
`MOBILE_ALLOW_CLEARTEXT_FOR_DEV`, set in `frontend.defaults` — and
`NSAllowsLocalNetworking` on iOS; both dev-only).
The trade-off is that the WebView can no longer read the `csrftoken` cookie from
`document.cookie`. So the CSRF token is delivered out-of-band by the session
exchange and cached in `localStorage`:
- `csrf.ts` stores/reads it under `messages_native-csrf-token`.
- `getCSRFToken()` (`src/features/api/utils.ts`) returns the native token on
native platforms and the web token otherwise; `getHeaders()` echoes it as
`X-CSRFToken`. This works with the backend's `CSRF_USE_SESSIONS` (the secret
lives in the session, replayed by the native cookie jar).
**Downloads** can't use `<a download>`: on native it escapes the WebView into the
system browser, which has no session and gets a 401. `nativeDownloadFile()`
fetches the bytes through `CapacitorHttp` (carrying the session), writes them to
`Directory.Cache` and hands them to the OS share sheet. Used by the thread-view
attachment components.
## OTA (over-the-air) updates
The JS bundle can be replaced without a store release, driven entirely from JS
against a **public S3 bucket — no Capgo server** (`autoUpdate: false`; the
`@capgo/capacitor-updater` plugin is used only for its native download/set/reload
primitives). Because the bucket is world-readable, every bundle is **encrypted
and signed** (Capgo v2, RSA+AES) with a per-instance key: the public half is
baked into the app at `cap sync` time (`capacitor.config.ts`, `publicKey`
`MOBILE_OTA_SIGNING_PUBLIC_KEY_B64`), the private half signs at publish time
(`MOBILE_OTA_SIGNING_PRIVATE_KEY_B64`, CI-only). A substituted zip therefore fails
native verification instead of running arbitrary code. The key is mandatory as
soon as OTA is on: `ota.ts` refuses to apply a manifest when the build embeds no
`MOBILE_OTA_SIGNING_PUBLIC_KEY_B64` (and `cap sync` still fails when the
deprecated baked `NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL` is set without it), so an
OTA-enabled app can never apply an unverified bundle.
- **Publish** (`make ota-publish [VERSION=x] [CHANNEL=x]`, i.e. the frontend
node script `src/frontend/scripts/publish-ota.mjs` — no Django involved):
`capgo bundle zip` `dist/` (with `index.html` at the zip root), `capgo bundle
encrypt` it (→ encrypted zip + encrypted `checksum` + `ivSessionKey`), upload
the *encrypted* zip to `channels/<channel>/bundles/<version>.zip`, and write
`channels/<channel>/manifest.json` = `{version, url, checksum, sessionKey}`
(the last two feed native verification). Publishing also refuses a version
that does not order above the channel's current manifest (mirror of the
client-side downgrade guard, see *Bundle versioning*; `--force` overrides).
- **Consume** (`src/features/native/ota.ts`, called at startup):
`notifyOtaAppReady()` first (confirms the running bundle booted, so
a broken update auto-rolls-back on next launch), then
`checkAndApplyOtaUpdate()` polls the manifest URL served by the backend
`MOBILE_OTA_MANIFEST_URL` setting (`/config` endpoint, resolved in
`bootstrap.tsx`) and applies the
advertised bundle **only if it is genuinely newer** — it must differ from
`CapacitorUpdater.current()`, carry a *strictly greater* version count (see
*Bundle versioning*), and not be recorded as a prior failed
boot (see *Rollback*). It then downloads (passing `checksum` + `sessionKey`,
verified against the baked-in public key) and `set()`s it, which reloads the
WebView.
OTA replaces the *web* bundle only. Anything native (a new Capacitor plugin, a
permission, the Swift/Gradle side) still requires a store release.
### Release channels
The bucket hosts one **self-contained folder per channel**
`channels/<channel>/manifest.json` plus its `bundles/` — and each app follows
exactly one channel: the channel segment lives in the `MOBILE_OTA_MANIFEST_URL`
served by the backend the app talks to (`/config` endpoint). The deploy
pipeline publishes to `staging` and `prod`; local development uses `dev`
(commented default in `env.d/development/backend.defaults`), so experiments
never look like a release.
**A bundle is never copied or promoted across channels.** The `NEXT_PUBLIC_*`
vars (API origin, …) are inlined into the web bundle at build
time, so a staging build *is not* a prod build pointed elsewhere — it is a
different artifact targeting the staging backend, whose `/config` in turn pins
the staging channel. Releasing to prod means
rebuilding with the prod env and publishing to the prod channel. Keeping the
zips under their channel also prevents two channels publishing the same commit
(same `<count>-<sha>` id) from overwriting each other's bundle.
The publish target comes from `MOBILE_OTA_CHANNEL` (or `--channel` /
`make ota-publish CHANNEL=…`), and must match the channel the apps follow
through the backend `MOBILE_OTA_MANIFEST_URL`: publishing a bundle built for
another environment would strand the fleet on that other backend's config.
### Generating the signing key pair
Each deployment generates its **own** RSA-2048 pair once (the two halves must
stay a matched set — the app rejects any bundle it can't verify):
```bash
make mobile-ota-keygen
```
It prints the two values ready to paste into an env file / CI secret store
(the guidance goes to stderr, so stdout stays a clean pair):
```
MOBILE_OTA_SIGNING_PUBLIC_KEY_B64=… # baked into the app build env (capacitor.config.ts)
MOBILE_OTA_SIGNING_PRIVATE_KEY_B64=… # publish-time secret — CI only, never commit
```
Both are single-line base64 PEMs (PKCS1 — the format `capgo bundle encrypt`
expects) so they survive Docker `env_file` and CI secret stores. The **public
half** goes into the app build env; the **private half signs bundles at publish
time and must stay a CI secret**. Rotating the pair requires shipping a new store
build (the public key is baked in), so treat it as long-lived.
### Bundle versioning
The manifest `version` (and the `channels/<channel>/bundles/<version>.zip` key)
is a **hybrid id**, `<count>-<sha>` — e.g. `1234-a1b2c3d`:
- `<count>` = `git rev-list --count HEAD`, a **monotonic** commit count that
orders releases;
- `<sha>` = `git rev-parse --short HEAD`, tracing the exact source commit.
The Makefile derives it once as `MOBILE_OTA_BUILD_ID`; `make ota-publish` uses it as the
default `VERSION` (override with `VERSION=…` to pin a release). The `version`
field is a free-form string — Capgo treats it as a *"version code/name"* and does
**not** require semver — so a commit-based id is fine. We use `-` (not the semver
`+` build-metadata separator) to keep the id safe in the bundle URL/S3 key.
The count drives **ordering, and the client enforces it**: `checkAndApplyOtaUpdate()`
applies a manifest only when its count is *strictly greater* than the running
bundle's, so **republishing an older build cannot downgrade the fleet** (an
accidental old publish or a replayed old bundle is refused). A bare SHA would
carry no such order. Ids without the numeric prefix — the literal `"builtin"`, or
a manually pinned non-hybrid version — can't be ordered and fall back to a plain
inequality check. Because the count comes from `git rev-list --count HEAD`, it is
only monotonic **along a single line of history**: always publish OTA from the
release branch, or two diverging branches can mint colliding counts.
**Builtin stamping.** `make mobile-build` passes `MOBILE_OTA_BUILD_ID` to `cap sync`,
which stamps it as the store build's builtin bundle version
(`CapacitorUpdater.version` in `capacitor.config.ts`). Without it the builtin
reports the literal `"builtin"`, so the first launch after a store install always
re-downloads. With it, a first launch whose manifest points at the **same** commit
skips the download; a **newer** manifest still updates — the normal case, since
OTA runs ahead of the store.
### Rollback
There are two kinds, plus a per-device safety net:
- **Automatic (a bundle that fails to boot).** If the new bundle never calls
`notifyAppReady()` (crash / white screen), the plugin reverts to the last
good bundle — the builtin if there is none — on the next launch and records
the version as its *last failed update*. `checkAndApplyOtaUpdate()` mirrors
that record (which self-clears on read) into WebView storage and refuses to
re-apply the version, so a broken publish can't trap the app in a
download → crash → revert → re-download loop. The record is boot-specific:
a transient download failure does not blacklist the version, it is simply
retried on the next check.
- **Deliberate (a bundle that boots but is bad).** You **cannot** point the
manifest back at the older, lower-count bundle — the downgrade guard refuses
it. Roll *forward* instead: `git revert` the bad commit(s) and
`make ota-publish`. The revert has a **higher** count, so it passes the guard
and the fleet converges onto the (restored) good code with a clean git trail.
Escape hatch if you can't revert: `make ota-publish VERSION=<count-above-current>-<oldsha>`
from the old build — but that breaks the count↔commit invariant, so prefer the
revert.
- **Per-device safety net.** `CapacitorUpdater.reset()` returns a single device to
the builtin (store) bundle, which is always bootable. Not fleet-wide; useful to
wire onto a support/debug action.
**Never prune old bundles from the bucket** — the plugin's fallback and any
revert build may still reference them.
## Codebase map
| Concern | Location |
| --- | --- |
| Capacitor config (appId via `MOBILE_APP_ID`, plugins, HTTP, SystemBars, OTA signing key) | `src/frontend/capacitor.config.ts` |
| Platform detection | `src/frontend/src/features/native/platform.ts` |
| PKCE helpers | `src/frontend/src/features/native/pkce.ts` |
| System-browser session | `src/frontend/src/features/native/auth-session.ts` |
| Native login / logout | `src/frontend/src/features/native/auth.ts` |
| Native CSRF token store | `src/frontend/src/features/native/csrf.ts` |
| Native download → share | `src/frontend/src/features/native/download.ts` |
| OTA client | `src/frontend/src/features/native/ota.ts` |
| Startup wiring (OTA, `native` html class) | `src/frontend/src/main.tsx` |
| CSRF / API origin wiring | `src/frontend/src/features/api/utils.ts` |
| Login/logout routing | `src/frontend/src/features/auth/index.tsx` |
| iOS ASWebAuthenticationSession plugin | `src/frontend/ios/App/App/WebAuthSessionPlugin.swift` |
| iOS plugin registration | `src/frontend/ios/App/App/MainViewController.swift` |
| SSO invariants tripwire (CI guard on the native declarations) | `src/frontend/src/features/native/sso-invariants.test.ts` |
| Android project | `src/frontend/android/` |
| Backend mobile-aware OIDC views | `src/backend/core/authentication/views.py` |
| Backend token → session exchange & mobile logout | `src/backend/core/api/viewsets/mobile_auth.py` |
| OTA publish scripts | `src/frontend/scripts/publish-ota.mjs`, `create-ota-bucket.mjs`, `ota-lib.mjs` |
**Native/web branching contract:** the single source of truth is
`isNativePlatform()`. `main.tsx` also tags `<html class="native">` so stylesheets
can opt into mobile-only chrome without every component re-deriving the platform.
## Prerequisites
### Common (all developers)
- The **dev stack** running: `make bootstrap` once, then `make start` (or
`make start-minimal`). Backend on `:8901`, Keycloak on `:8902`, object storage
on `:8906`.
- **No host Node toolchain is required**: the web bundle is built inside the
`frontend-mobile` container — see *Build & run workflow*. The host only needs
the native toolchains below. If you do run `npm` on the host anyway, it must
be **Node 22** (`>=22 <23`): any other version corrupts the lockfile and
breaks the container build.
- Backend settings must allowlist the scheme:
`MOBILE_AUTH_CALLBACK_SCHEMES=["stmessages"]` (empty list = mobile login
disabled). See [env.md](./env.md).
### Android
If you have never set up an Android toolchain, follow [Capacitor's environment
setup guide](https://capacitorjs.com/docs/getting-started/environment-setup#android-requirements)
end-to-end first; the list below is what this project specifically needs.
- **Android Studio** (latest stable) with the Android SDK —
[install guide](https://developer.android.com/studio/install).
- **SDK levels**: `compileSdk 36` / `targetSdk 36`, `minSdk 24`. Install SDK 36
+ build-tools via the [SDK Manager](https://developer.android.com/studio/intro/update#sdk-manager).
- **JDK 17+** (bundled with recent Android Studio).
- **[`adb`](https://developer.android.com/tools/adb)** on the `PATH` (host), for
install + port forwarding. Heads-up: the `adb reverse` tunnel to the dev stack
is dropped on **every emulator reboot** — rerun `make mobile-android-reverse`
(details under *Build & run workflow*).
- An **[emulator image](https://developer.android.com/studio/run/managing-avds)
with Play services** (Google Play / Google APIs). Chrome
Custom Tabs needs it; a bare AOSP image falls back to an isolated-cookie
WebView and **breaks cross-app SSO** (a common false negative). A physical
device always ships Chrome, so it can't hit this.
### iOS
If you have never set up an iOS toolchain, follow [Capacitor's environment
setup guide](https://capacitorjs.com/docs/getting-started/environment-setup#ios-requirements)
end-to-end first; the list below is what this project specifically needs.
- A **Mac** — non-negotiable for iOS builds.
- **[Xcode](https://developer.apple.com/xcode/) 16+** with the iOS 16+ SDK.
Deployment target is **iOS 15**.
- Dependencies are managed by **Swift Package Manager** (pinned in
`ios/App/CapApp-SPM/Package.swift`) — **no CocoaPods / Podfile**. Xcode
resolves the packages on first open.
- For a physical device: an Apple developer account and a signing team
configured in Xcode — see [running your app on a device](https://developer.apple.com/documentation/xcode/running-your-app-in-simulator-or-on-a-device).
## Build & run workflow
The web bundle is built **in a container** (`frontend-mobile`) so the
`NEXT_PUBLIC_*` vars from `env.d/development/frontend.{defaults,local}` are
inlined at build time (Vite `envPrefix: 'NEXT_PUBLIC_'`). Building on the host
with a bare `npm run build` would inline none of them. The native compile, IDE,
`adb` and Xcode steps run on the **host**.
> **Always run `make mobile-build` after a fresh checkout.** `cap sync` (not a
> bare copy) also regenerates the gitignored
> `capacitor-cordova-android-plugins/` scaffolding that Gradle needs.
| Command | What it does |
| --- | --- |
| `make mobile-build` | web build (container) + `cap sync` into `ios/` and `android/` |
| `make mobile-assets` | regenerate native icons & splashscreens from `src/frontend/assets/` |
| `make mobile-android` | `mobile-build`, then open the Android project in Android Studio (host) |
| `make mobile-android-run` | `mobile-build` + `gradlew assembleDebug` + `adb install` + `adb reverse` (host) |
| `make mobile-android-reverse` | (re)apply the `adb reverse` port mapping |
| `make mobile-ios` | `mobile-build`, then open the Xcode project (host, macOS) |
| `make mobile-ota-keygen` | generate a per-instance OTA signing key pair (base64 PEMs) |
| `make mobile-ota-bucket` | create the public `messages-ota` bucket |
| `make ota-publish [VERSION=x] [CHANNEL=x]` | build + publish a signed OTA bundle and its channel manifest (VERSION defaults to `<count>-<sha>`, CHANNEL to `MOBILE_OTA_CHANNEL`) |
**Android port forwarding.** The in-app WebView reaches the dev stack through an
`adb reverse` tunnel for ports **8900, 8901, 8902, 8906** (frontend, backend,
Keycloak, object storage). It is dropped on every emulator reboot / adb
reconnection and is **not** re-applied by Android Studio — rerun
`make mobile-android-reverse` whenever the app suddenly can't reach the backend.
The same tunnel works over USB for a physical device (enable Developer options +
USB debugging first). With several devices attached, pin one with
`export ANDROID_SERIAL=<serial>` (`adb devices` to list).
**iOS** needs no tunnel: the simulator reaches the host's `localhost` directly.
Run the `App` scheme after `make mobile-ios`.
### Hot reload (on by default in dev)
`MOBILE_DEV_SERVER_URL` — set to `http://localhost:8900` (the Vite dev server)
in `env.d/development/frontend.defaults` — is baked by `cap sync` into the app
as Capacitor's `server.url`: the WebView loads the app straight from Vite
instead of the embedded `dist/`, so JS/CSS changes apply through HMR without
rebuilding or reinstalling. Since every `make mobile-*` target runs in a
container carrying the frontend env files, **any dev build gets hot reload out
of the box**. Requirements and caveats:
- The dev stack must be up (`make run` / the `frontend-dev` service): the app
is blank otherwise. `localhost:8900` is routed by the same `adb reverse`
tunnel as the backend on Android, and by the shared loopback on the iOS
simulator. For a **physical iPhone** (no tunnel), point it at the Mac's LAN
IP in `frontend.local`: `MOBILE_DEV_SERVER_URL=http://<mac-ip>:8900` (ATS
exempts raw IP literals, so plain HTTP works).
- Native changes (plugins, `ios/`, `android/`, `capacitor.config.ts`) still
need a rebuild — hot reload only covers the web bundle.
- The startup OTA check is skipped during a hot reload session (`ota.ts` skips
when `import.meta.env.DEV` **and** `MOBILE_DEV_SERVER_URL` are set): applying
a downloaded bundle would yank the WebView off the dev server mid-session.
**Disabling it** — to test the embedded bundle (what a store build ships), or
the OTA chain end to end: set the variable **empty** in
`env.d/development/frontend.local` (gitignored, overrides the defaults):
```bash
# env.d/development/frontend.local
MOBILE_DEV_SERVER_URL=
```
then rerun `make mobile-build` (or any target that wraps it) and reinstall the
app. A leftover `server.url` fails Android **release** builds (gradle guard in
`android/app/build.gradle`); see the release checklist for iOS.
## Configuration
Mobile-specific environment variables (full reference in [env.md](./env.md)):
| Variable | Purpose |
| --- | --- |
| `MOBILE_APP_ID` | Store/OS bundle identifier (default `local.suitenumerique.messages`). Read by `cap sync` (container) **and** the native builds (gradle `applicationId`, iOS `PRODUCT_BUNDLE_IDENTIFIER`), so it must be exported in both contexts. Independent of the `stmessages` auth scheme |
| `MOBILE_AUTH_CALLBACK_SCHEMES` | JSON list of allowlisted deep-link schemes (e.g. `["stmessages"]`); empty disables mobile login |
| `MOBILE_DEV_SERVER_URL` | Dev only: Vite dev server URL baked as Capacitor `server.url` at `cap sync` (hot reload). Set to `http://localhost:8900` in `frontend.defaults`; disable with an empty value in `frontend.local`; never set for release builds (see *Hot reload*) |
| `MOBILE_ALLOW_CLEARTEXT_FOR_DEV` | Dev only: baked as Capacitor `server.cleartext` at `cap sync` (`android:usesCleartextTraffic`), allowing plain HTTP to the dev backend / Vite / RustFS. Set to `1` in `frontend.defaults`; never set for release builds — the manifest then stays cleartext-free |
| `MOBILE_AUTH_TOKEN_TTL` | Lifetime (s) of the one-time exchange token (default 60) |
| `NEXT_PUBLIC_API_ORIGIN` | API base URL — **must be set explicitly** for mobile builds (no meaningful `window.location.origin` in the WebView) |
| `MOBILE_OTA_MANIFEST_URL` | Backend setting served through `/config`: OTA channel manifest polled at startup — the followed channel changes without a new native build; unset disables OTA (deprecated build-time fallback: `NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL`) |
| `MOBILE_OTA_CHANNEL` | Release channel `ota-publish` targets (`dev` locally, `staging`/`prod` in the pipeline); must match the channel the build follows (see *Release channels*) |
| `MOBILE_OTA_S3_*`, `MOBILE_OTA_PUBLIC_BASE_URL` | OTA publish: S3 write credentials/endpoint (frontend env, not Django) and the device-reachable public base URL written into the manifest |
| `MOBILE_OTA_SIGNING_PUBLIC_KEY_B64` | Base64 PEM public key baked into the app (`capacitor.config.ts`, native verification) and inlined by Vite (`ota.ts` refuses a server-provided manifest URL without it); required for any OTA-enabled build |
| `MOBILE_OTA_SIGNING_PRIVATE_KEY_B64` | Base64 PEM private key that signs bundles at publish time (`publish-ota.mjs`, CI-only) |
## Production hardening / known gaps
The following are POC-scoped shortcuts that must be resolved before shipping.
Treat this list as the "definition of ready for production".
- **OTA over HTTPS.** Bundle signing/encryption (Capgo v2, RSA+AES) and a
strictly-increasing version guard are in place (see the OTA section), so a
substituted or replayed old zip is refused. What remains for production is to
serve the bucket/CDN over **HTTPS** (dev uses cleartext RustFS). A hard
minimum-version *floor* baked into the app — rejecting anything below a known
release regardless of the running bundle — would further harden a device stuck
on a very old build, but the monotonic guard already covers accidental
downgrades.
- **Move off custom URL schemes.** Custom schemes can be claimed by other apps
(mitigated today by the one-time token + PKCE). Production should move to
**Universal Links (iOS) / App Links (Android)**.
- **CSRF `Origin` on HTTPS.** Native requests carry no `Origin`/`Referer`, which
Django requires on secure requests. The fetch wrapper must inject an `Origin`
listed in `CSRF_TRUSTED_ORIGINS`.
- **Cleartext transport is dev-only, build-gated on both platforms.** On
Android, `preReleaseBuild` fails when the synced `capacitor.config.json`
carries a dev `server.url` or `server.cleartext` (i.e. when
`MOBILE_DEV_SERVER_URL` / `MOBILE_ALLOW_CLEARTEXT_FOR_DEV` was in the
`cap sync` env). On iOS, the "Strip dev ATS exception" build phase deletes
the `NSAppTransportSecurity` dict (`NSAllowsLocalNetworking`) from the built
product in every non-Debug configuration, so it never ships in an Archive.
- **IdP logout & session renewal.** Logout ends the Django session but not
the IdP one; the 12 h Django session has no refresh-token renewal yet.
Confirm ProConnect SSO session duration and persistent-cookie behaviour
(esp. iOS) in production.
- **Safe-area insets.** Disabling Capacitor's `SystemBars` inset handling (to fix
the double keyboard inset, Capacitor #8181) means Android no longer receives
the `--safe-area-inset-*` CSS variables; `MainActivity.java` re-injects them
from the window insets (system bars + display cutout), without touching the
keyboard behavior. iOS resolves `env(safe-area-inset-*)` natively. The app
shell folds the top inset into `--header-height` (`globals.scss`), so
anything laid out from it clears the status bar / notch automatically.
- **Iframe subresources.** Inline images proxied through the API use the WebView
network stack, not the native one, and may not load in dev; the HTML body
itself renders.
- **App Store guideline 4.2.** A pure web wrapper needs native-feeling
differentiators (push notifications, share targets…) to pass review.
## Release checklist (manual)
Some load-bearing behaviors cannot fail loudly: when they regress, **login
still works** and only the invisible part disappears, so no error ever
surfaces in development. Run this checklist before every store release, and
after any change to the native projects (`ios/`, `android/`), the auth plumbing
or the Capacitor version.
1. **iOS cross-app SSO***the* critical, silent one. It rests on
`WebAuthSessionPlugin.swift` using `ASWebAuthenticationSession` with
`prefersEphemeralWebBrowserSession = false`, its registration in
`MainViewController.swift`, and a **persistent** IdP cookie. Regenerating the
iOS project or "simplifying" back to the default `Browser` plugin
(SFSafariViewController — cookie store isolated from Safari) silently turns
the second app's silent login back into a credential prompt.
Run the [two-app procedure](./mobile-poc.md#re-testing-cross-app-sso-with-a-second-app)
and its objective proofs (Keycloak events, negative control). The
`sso-invariants.test.ts` tripwire pins the files (flag, registration,
schemes) so the most likely mechanical regressions turn CI red, but it
cannot prove the runtime behavior — this manual test stays mandatory.
For a **store release, run it against the production IdP**: the IdP-side
half of the contract (ProConnect silently reusing its session for
`acr_values=eidas1`, persistent cookie) lives outside this repo and no
CI or dev-realm check can stand in for it.
2. **Android cross-app SSO** — same two-app procedure through Chrome Custom
Tabs. Beware the false negative: an emulator without Play services falls
back to an isolated-cookie WebView (see *Prerequisites*).
3. **Thread rendering** — open a thread: the message body iframe
(`srcDoc` + `sandbox` + CSP) must render. This is what killed the previous
React Native attempt; a WebView/Capacitor upgrade can regress it.
4. **OTA chain on the release channel** — publish to the channel the build
follows, relaunch, verify the new bundle applies; then confirm a lower-count
manifest is refused (downgrade guard).
5. **Native file paths** — download/share an attachment and a raw `.eml`
(native HTTP session), upload an attachment (CSRF token path).
6. **Logout → re-login** — logout ends the Django session only; the
following login must complete silently (IdP session preserved).
7. **No dev server baked in** — in dev, `MOBILE_DEV_SERVER_URL` bakes the Vite
dev server URL into `capacitor.config.json` (hot reload, see *Build & run
workflow*). Before archiving, set it empty in `frontend.local` and rerun
`make mobile-build`. Android release builds fail on a leftover `server.url`
(gradle guard in `android/app/build.gradle`); Xcode has no equivalent guard,
so **check manually for iOS** (no `server.url` in
`ios/App/App/capacitor.config.json`).
## See also
- [`mobile-poc.md`](./mobile-poc.md) — validation procedures: backend-only smoke
test with curl, running on emulators/devices, re-testing cross-app SSO with a
throwaway second app, and objective SSO proofs (Keycloak events, negative
controls).
- [`env.md`](./env.md) — full environment-variable reference.
+6
View File
@@ -77,6 +77,12 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Mobile apps (Capacitor) session handoff
MOBILE_AUTH_CALLBACK_SCHEMES=["stmessages"]
# Mobile apps (Capacitor) OTA — OPT-IN in dev: channel manifest URL served to
# the apps through /config (see ota.ts). Uncomment (working dev-stack value,
# device-reachable via adb reverse) together with the publishing block in
# frontend.defaults to exercise the OTA chain.
# MOBILE_OTA_MANIFEST_URL=http://localhost:8906/messages-ota/channels/dev/manifest.json
# keycloak
IDENTITY_PROVIDER=keycloak
KEYCLOAK_REALM=messages
+50
View File
@@ -33,3 +33,53 @@ MOBILE_APP_ID=local.suitenumerique.messages
## loopback (iOS). To disable (embedded bundle, e.g. before a release build),
## set it EMPTY in frontend.local: MOBILE_DEV_SERVER_URL=
MOBILE_DEV_SERVER_URL=http://localhost:8900
## Mobile cleartext transport (Capacitor, Android). Baked as server.cleartext
## at `cap sync` (capacitor.config.ts) — i.e. android:usesCleartextTraffic in
## the app manifest — so plain HTTP works in dev: the WebView reaching the Vite
## dev server AND the native fetch/OTA layer reaching http://localhost:8901 /
## RustFS (required even with hot reload disabled, e.g. when testing the OTA
## chain). Must NEVER be set for a release build. To disable, set it EMPTY in
## frontend.local: MOBILE_ALLOW_CLEARTEXT_FOR_DEV=
MOBILE_ALLOW_CLEARTEXT_FOR_DEV=1
## Mobile OTA (Capacitor) — OPT-IN in dev: uncomment the publishing vars below
## (values are the working dev-stack ones) to exercise the OTA chain. The
## channel manifest URL the app polls at startup is served by the backend
## /config endpoint: set MOBILE_OTA_MANIFEST_URL in backend.local (dev value:
## http://localhost:8906/messages-ota/channels/dev/manifest.json). Its channel
## segment must match MOBILE_OTA_CHANNEL below — the app follows one channel,
## publishing writes to one channel. Note: during a hot reload session
## (MOBILE_DEV_SERVER_URL set), the startup OTA check is skipped even when the
## manifest URL is configured.
## Mobile OTA publishing (used by `make mobile-ota-bucket` / `make ota-publish`, i.e.
## scripts/*-ota*.mjs — NOT inlined by Vite). The S3 endpoint is where the
## script writes (compose network); MOBILE_OTA_PUBLIC_BASE_URL is the device-reachable
## read URL written into the manifest (RustFS host port, see adb reverse).
## MOBILE_OTA_S3_KEY_PREFIX stays empty for a dedicated bucket root. MOBILE_OTA_CHANNEL is the
## default publish target: `dev` locally so experiments never look like a real
## release; the deploy pipeline uses `staging` and `prod`, each with its own
## build (NEXT_PUBLIC_* vars are inlined at build time — bundles are never
## copied across channels).
# MOBILE_OTA_CHANNEL=dev
# MOBILE_OTA_S3_ENDPOINT=http://objectstorage:9000
# MOBILE_OTA_S3_BUCKET=messages-ota
# MOBILE_OTA_S3_ACCESS_KEY=st-messages
# MOBILE_OTA_S3_SECRET_KEY=password
# MOBILE_OTA_S3_KEY_PREFIX=
# MOBILE_OTA_PUBLIC_BASE_URL=http://localhost:8906/messages-ota
## Mobile OTA signing (Capgo v2, RSA+AES). Bundles are signed at publish time and
## verified natively against the public key baked into the app, so a substituted
## zip on the public bucket is rejected. Both are base64-encoded PEMs (single
## line, to survive env_file / CI secrets): PUBLIC is read by capacitor.config.ts
## at `cap sync`, PRIVATE by scripts/publish-ota.mjs.
## NO VALUE IS COMMITTED — even a worthless throwaway private key in git keeps
## tripping secret scanners and risks being copied into a real env. This is the
## one OTA block you can't just uncomment: run `make mobile-ota-keygen` once and
## paste both printed values into frontend.local. Each deployment likewise
## generates its own pair and stores the private half as a CI secret.
# MOBILE_OTA_SIGNING_PUBLIC_KEY_B64=
# MOBILE_OTA_SIGNING_PRIVATE_KEY_B64=
+5
View File
@@ -389,6 +389,11 @@
}
},
"readOnly": true
},
"MOBILE_OTA_MANIFEST_URL": {
"type": "string",
"description": "OTA channel manifest URL the mobile apps poll at startup; unset disables OTA updates",
"readOnly": true
}
},
"required": [
+11
View File
@@ -231,6 +231,17 @@ CONFIG_ENTRIES = (
},
required=False,
),
ConfigEntry(
"MOBILE_OTA_MANIFEST_URL",
{
"type": "string",
"description": (
"OTA channel manifest URL the mobile apps poll at startup; "
"unset disables OTA updates"
),
},
required=False,
),
)
@@ -1,5 +1,7 @@
"""Management command to create storage buckets and configure lifecycle rules."""
import json
from django.core.files.storage import storages
from django.core.management.base import BaseCommand
@@ -24,6 +26,11 @@ class Command(BaseCommand):
default=0,
help="Auto-expire objects after this many days (0 = no expiration)",
)
parser.add_argument(
"--public",
action="store_true",
help="Grant anonymous read access to all objects (public bucket)",
)
def handle(self, *args, **options):
storage = storages[options["storage"]]
@@ -59,3 +66,27 @@ class Command(BaseCommand):
f"Lifecycle rule set: objects expire after {expire_days} day(s)."
)
)
# Grant anonymous read access (e.g. mobile OTA bundles served directly
# from the bucket). Only intended for non-sensitive, public artifacts.
if options["public"]:
s3_client.put_bucket_policy(
Bucket=bucket,
Policy=json.dumps(
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": f"arn:aws:s3:::{bucket}/*",
}
],
}
),
)
self.stdout.write(
self.style.SUCCESS(f"Bucket '{bucket}' is now publicly readable.")
)
@@ -85,6 +85,7 @@ def test_api_config(is_authenticated):
assert "FRONTEND_HELP_CENTER_URL" not in response.json()
assert "FRONTEND_FEEDBACK_WIDGET_CONFIG" not in response.json()
assert "FRONTEND_LAGAUFRE_WIDGET_CONFIG" not in response.json()
assert "MOBILE_OTA_MANIFEST_URL" not in response.json()
@override_settings(
@@ -128,6 +129,7 @@ def test_api_config_with_external_services():
"api_url": "https://lagaufre.example.com",
"path": "https://lagaufre.example.com/static/",
},
MOBILE_OTA_MANIFEST_URL="https://static.example.com/ota/channels/prod/manifest.json",
)
def test_api_config_frontend_settings():
"""Frontend settings configured on the backend should be exposed as-is."""
@@ -148,6 +150,9 @@ def test_api_config_frontend_settings():
assert config["FRONTEND_LAGAUFRE_WIDGET_CONFIG"]["api_url"] == (
"https://lagaufre.example.com"
)
assert config["MOBILE_OTA_MANIFEST_URL"] == (
"https://static.example.com/ota/channels/prod/manifest.json"
)
@override_settings(MESSAGE_TRUSTED_LINK_DOMAINS=["gouv.fr", "*.example.com"])
+8
View File
@@ -1074,6 +1074,14 @@ class Base(Configuration):
MOBILE_AUTH_TOKEN_TTL = values.PositiveIntegerValue(
default=60, environ_name="MOBILE_AUTH_TOKEN_TTL", environ_prefix=None
)
# OTA channel manifest the mobile 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 `mobile:ota:publish` writes
# to for this deployment. Unset disables OTA (unless a build still bakes
# the deprecated NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL fallback).
MOBILE_OTA_MANIFEST_URL = values.Value(
None, environ_name="MOBILE_OTA_MANIFEST_URL", environ_prefix=None
)
LOGIN_REDIRECT_URL = values.Value(
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
)
+56 -2
View File
@@ -1,5 +1,40 @@
import type { CapacitorConfig } from "@capacitor/cli";
// OTA signing public key, per-instance and injected at `cap sync` time from a
// base64-encoded PEM (single line, so it survives docker env_file / CI secrets).
// The matching private key signs bundles at publish time (see publish-ota.mjs);
// baking the public half here lets the native updater verify each downloaded
// bundle. Unset is only allowed when OTA itself is off (no manifest URL, e.g.
// a web-only build) — see the guard below.
const otaPublicKeyB64 = process.env.MOBILE_OTA_SIGNING_PUBLIC_KEY_B64;
const otaPublicKey = otaPublicKeyB64
? Buffer.from(otaPublicKeyB64, "base64").toString("utf8")
: undefined;
// An OTA-enabled app without a baked-in verification key would apply any
// unsigned bundle from the world-readable bucket — refuse to build. Publishing
// already requires the private half (publish-ota.mjs), so a key-less OTA build
// could never receive a legitimate update anyway. This sync-time guard only
// sees the deprecated baked manifest URL: the nominal path (URL served by the
// backend MOBILE_OTA_MANIFEST_URL setting through /config) is covered by the
// equivalent runtime refusal in src/features/native/ota.ts.
if (process.env.NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL && !otaPublicKey) {
throw new Error(
"NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL is set but " +
"MOBILE_OTA_SIGNING_PUBLIC_KEY_B64 is missing: an OTA-enabled build " +
"must embed the signing public key (run `make mobile-ota-keygen`, see " +
"env.d/development/frontend.defaults).",
);
}
// Release id stamped into the *builtin* bundle so a fresh store install reports
// the same version an OTA manifest advertises — otherwise the builtin reports
// the literal "builtin" and the first launch always re-downloads (see
// docs/mobile.md, "Bundle versioning"). Derived from git at build time
// (Makefile MOBILE_OTA_BUILD_ID); unset (e.g. web-only build) ⇒ the plugin falls back
// to the native versionName.
const otaBuildId = process.env.MOBILE_OTA_BUILD_ID;
// Dev-only hot reload: when set, the WebView loads the app straight from the
// Vite dev server instead of the embedded dist/, so JS/CSS changes apply
// through HMR without rebuilding or reinstalling the app. Set by default in
@@ -19,8 +54,15 @@ const config: CapacitorConfig = {
appName: "Messages",
webDir: "dist",
server: {
// Dev only: allows the Android WebView to reach http://localhost:8901.
cleartext: true,
// Dev only: `cap sync` turns this into android:usesCleartextTraffic in the
// Android manifest, allowing plain HTTP for the whole app process — the
// WebView loading the Vite dev server as well as the native HTTP layer
// reaching the http://localhost:8901 backend and the RustFS OTA bucket
// (needed even with hot reload disabled, hence the dedicated flag). Unset
// for release builds: the manifest then stays cleartext-free.
cleartext: Boolean(
devServerUrl || process.env.MOBILE_ALLOW_CLEARTEXT_FOR_DEV,
),
...(devServerUrl ? { url: devServerUrl } : {}),
},
plugins: {
@@ -43,6 +85,18 @@ const config: CapacitorConfig = {
SystemBars: {
insetsHandling: "disable",
},
// OTA live updates driven entirely from JS against an S3-hosted manifest
// (see src/features/native/ota.ts). autoUpdate is off so the plugin never
// talks to a Capgo server: we only use its native download/set/reload.
CapacitorUpdater: {
autoUpdate: false,
resetWhenUpdate: true,
// Verify each OTA bundle against the per-instance signing key (v2, RSA+AES).
...(otaPublicKey ? { publicKey: otaPublicKey } : {}),
// Report this id (not "builtin") for the shipped bundle, so the OTA
// freshness check can match a manifest published from the same commit.
...(otaBuildId ? { version: otaBuildId } : {}),
},
},
};
+3
View File
@@ -25,6 +25,9 @@
"i18n:extract": "i18next-cli extract",
"analyze": "ANALYZE=1 vite build && node ./scripts/print-bundle-stats.mjs",
"mobile:build": "npm run build && npx cap sync",
"mobile:ota:bucket": "node scripts/create-ota-bucket.mjs",
"mobile:ota:keygen": "node scripts/generate-ota-keys.mjs",
"mobile:ota:publish": "node scripts/publish-ota.mjs",
"mobile:assets": "capacitor-assets generate --ios --android --pwa --iconBackgroundColor '#ffffff' --iconBackgroundColorDark '#161B28' --splashBackgroundColor '#ffffff' --splashBackgroundColorDark '#161B28'"
},
"dependencies": {
@@ -0,0 +1,41 @@
// Create the public OTA bucket and grant anonymous read on its objects. Used in
// development (`make mobile-ota-bucket`) to bootstrap the RustFS bucket the mobile app
// fetches bundles/manifest from. Public artifacts only — never sensitive data.
//
// Usage: node scripts/create-ota-bucket.mjs
import {
CreateBucketCommand,
HeadBucketCommand,
PutBucketPolicyCommand,
} from "@aws-sdk/client-s3";
import { otaConfig } from "./ota-lib.mjs";
const { client, bucket } = otaConfig();
try {
await client.send(new HeadBucketCommand({ Bucket: bucket }));
console.log(`Bucket '${bucket}' already exists.`);
} catch {
await client.send(new CreateBucketCommand({ Bucket: bucket }));
console.log(`Bucket '${bucket}' created.`);
}
await client.send(
new PutBucketPolicyCommand({
Bucket: bucket,
Policy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Sid: "PublicRead",
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: `arn:aws:s3:::${bucket}/*`,
},
],
}),
}),
);
console.log(`Bucket '${bucket}' is now publicly readable.`);
@@ -0,0 +1,31 @@
// Generate a per-instance OTA signing key pair (Capgo v2, RSA-2048, PKCS1 PEM).
// Prints both halves base64-encoded (single line) so they drop straight into an
// env file or a CI secret:
// - MOBILE_OTA_SIGNING_PUBLIC_KEY_B64 → baked into the app by capacitor.config.ts,
// lets the native updater verify each downloaded bundle.
// - MOBILE_OTA_SIGNING_PRIVATE_KEY_B64 → used by publish-ota.mjs to sign bundles.
// KEEP IT SECRET (CI secret in prod); never commit a real one.
//
// Each deployment (La Suite operator) runs this once and stores its own pair —
// the two halves must stay a matched set or the app rejects its own bundles.
//
// Usage: node scripts/generate-ota-keys.mjs
import { generateKeyPairSync } from "node:crypto";
// PKCS1 ("BEGIN RSA … KEY") is the format the Capgo CLI produces and expects;
// PKCS8 keys are rejected by `bundle encrypt` with "Invalid private key format".
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs1", format: "pem" },
privateKeyEncoding: { type: "pkcs1", format: "pem" },
});
const b64 = (pem) => Buffer.from(pem, "utf8").toString("base64");
// Guidance goes to stderr so stdout stays a clean, pipeable KEY=VALUE pair.
process.stderr.write(
"New OTA signing pair. Put the PUBLIC half in the app build env and the\n" +
"PRIVATE half in a secret (CI); both must come from the SAME run.\n\n",
);
process.stdout.write(`MOBILE_OTA_SIGNING_PUBLIC_KEY_B64=${b64(publicKey)}\n`);
process.stdout.write(`MOBILE_OTA_SIGNING_PRIVATE_KEY_B64=${b64(privateKey)}\n`);
+128
View File
@@ -0,0 +1,128 @@
// Shared S3 configuration for the mobile OTA publishing scripts. The bundles and
// the per-channel manifests live on a public bucket; publishing runs from the
// frontend toolchain (dev: `make ota-*` against RustFS; prod: CI against the
// target S3). Django is deliberately not involved — the OTA release is a
// frontend artifact.
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
/** Read a required env var or fail fast with a clear message. */
export const requireEnv = (name) => {
const value = process.env[name];
if (!value) {
console.error(`Missing required environment variable: ${name}`);
process.exit(1);
}
return value;
};
/**
* Build the S3 client and the key layout shared by publish/bucket scripts.
*
* `MOBILE_OTA_S3_KEY_PREFIX` lets a shared bucket host several apps under a path
* (e.g. `messages/mobileapp/`); it defaults to empty (dedicated bucket root).
* It only affects the object *keys* — the public read URL is derived from
* `MOBILE_OTA_PUBLIC_BASE_URL`, which the operator sets consistently with the prefix.
*/
export const otaConfig = () => {
const rawPrefix = process.env.MOBILE_OTA_S3_KEY_PREFIX ?? "";
const prefix =
rawPrefix && !rawPrefix.endsWith("/") ? `${rawPrefix}/` : rawPrefix;
return {
bucket: requireEnv("MOBILE_OTA_S3_BUCKET"),
prefix,
client: new S3Client({
endpoint: requireEnv("MOBILE_OTA_S3_ENDPOINT"),
// RustFS (dev) and most S3-compatible stores need path-style addressing.
forcePathStyle: true,
region: process.env.MOBILE_OTA_S3_REGION || "us-east-1",
credentials: {
accessKeyId: requireEnv("MOBILE_OTA_S3_ACCESS_KEY"),
secretAccessKey: requireEnv("MOBILE_OTA_S3_SECRET_KEY"),
},
}),
};
};
/**
* Validate a channel name so it stays safe as an S3 key segment and URL path.
* Exits with a clear message on anything else.
*/
export const validateChannel = (channel) => {
if (!/^[a-z0-9][a-z0-9._-]*$/.test(channel)) {
console.error(
`Invalid channel name '${channel}': lowercase letters, digits, ` +
"'.', '_' and '-' only.",
);
process.exit(1);
}
return channel;
};
/**
* Validate a version id so it stays safe as a local filename and S3 key
* segment (it names the bundle zip). Exits with a clear message on anything
* else — a `/` or `..` would escape the intended paths.
*/
export const validateVersion = (version) => {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version)) {
console.error(
`Invalid version '${version}': must start with a letter or digit, ` +
"then letters, digits, '.', '_' and '-' only.",
);
process.exit(1);
}
return version;
};
/**
* S3 key of a channel's manifest — the mutable pointer the apps poll. Each
* channel is self-contained (its bundles live under `channels/<channel>/`
* too): builds inline the NEXT_PUBLIC_* env, so bundles are never shared or
* copied across channels.
*/
export const manifestKey = (prefix, channel) => {
return `${prefix}channels/${channel}/manifest.json`;
};
/**
* Parse the monotonic ordering prefix of a hybrid `<count>-<sha>` version.
* Returns null for ids without it. Mirrors `versionCount()` in
* src/features/native/ota.ts (browser vs node context, kept in sync by hand).
*/
export const versionCount = (version) => {
const match = /^(\d+)-/.exec(version ?? "");
return match ? Number(match[1]) : null;
};
/** Read and parse a channel manifest, or return null when it does not exist. */
export const readManifest = async ({ client, bucket }, key) => {
try {
const response = await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
);
return JSON.parse(await response.Body.transformToString());
} catch (error) {
if (error?.name === "NoSuchKey" || error?.$metadata?.httpStatusCode === 404) {
return null;
}
throw error;
}
};
/** Write a channel manifest. */
export const writeManifest = async ({ client, bucket }, key, manifest) => {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: JSON.stringify(manifest),
ContentType: "application/json",
// Never let a CDN serve a stale manifest: it is the freshness signal.
CacheControl: "no-cache",
}),
);
};
+198
View File
@@ -0,0 +1,198 @@
// Publish a *signed* mobile OTA bundle. The bucket is world-readable, so a
// substituted zip would be arbitrary code in the WebView: every bundle is
// therefore encrypted+signed (Capgo v2, RSA+AES) with a per-instance private
// key before upload. The native updater verifies it against the public key
// baked into the app (see capacitor.config.ts, `publicKey`).
//
// Flow: `capgo bundle zip` (→ plaintext sha256) → `capgo bundle encrypt`
// (→ encrypted `*_encrypted.zip`, an encrypted checksum and an ivSessionKey) →
// upload the encrypted zip as `channels/<channel>/bundles/<version>.zip` →
// write `channels/<channel>/manifest.json` carrying that checksum + sessionKey,
// which the app passes back to CapacitorUpdater.download() (see
// src/features/native/ota.ts).
//
// Each channel is a self-contained folder, bundles included: the NEXT_PUBLIC_*
// vars are inlined into the web bundle at build time, so a staging build is NOT
// a prod build — never copy a bundle across channels, rebuild and republish for
// each. Keeping the zips under the channel also stops two channels publishing
// the same commit (same <count>-<sha> id) from overwriting each other.
//
// Usage: node scripts/publish-ota.mjs --version <x.y.z> [--channel <name>]
// [--dist ./dist] [--force]
// The channel falls back to the MOBILE_OTA_CHANNEL env var.
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseArgs } from "node:util";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import {
manifestKey,
otaConfig,
readManifest,
requireEnv,
validateChannel,
validateVersion,
versionCount,
writeManifest,
} from "./ota-lib.mjs";
const { values } = parseArgs({
options: {
version: { type: "string" },
channel: { type: "string" },
dist: { type: "string", default: "./dist" },
force: { type: "boolean", default: false },
},
});
const channel = values.channel ?? process.env.MOBILE_OTA_CHANNEL;
if (!values.version || !channel) {
console.error(
"Usage: node scripts/publish-ota.mjs --version <x.y.z> --channel <name> " +
"[--dist ./dist] [--force]\n" +
"The channel may also come from the MOBILE_OTA_CHANNEL env var.",
);
process.exit(1);
}
validateChannel(channel);
validateVersion(values.version);
const { version, dist } = values;
if (!existsSync(dist) || !statSync(dist).isDirectory()) {
console.error(`Dist directory not found: ${dist}`);
process.exit(1);
}
// Run a Capgo CLI subcommand and return the JSON it prints on stdout (`--json`).
// The CLI decorates progress on stderr, so we slice from the first `{` to the
// last `}` to stay robust against any stray prefix.
const capgo = (args) => {
const out = execFileSync("npx", ["--no-install", "@capgo/cli", ...args], {
encoding: "utf8",
});
const json = out.slice(out.indexOf("{"), out.lastIndexOf("}") + 1);
return JSON.parse(json);
};
const appId = process.env.MOBILE_APP_ID ?? "local.suitenumerique.messages";
// The CLI writes the zip (and its `*_encrypted.zip` sibling) next to the cwd.
const zipName = `${version}.zip`;
const encryptedZip = `${zipName}_encrypted.zip`;
const { client, bucket, prefix } = otaConfig();
const channelManifestKey = manifestKey(prefix, channel);
// Publish-side mirror of the client's downgrade guard (see ota.ts): devices
// would refuse a manifest whose count is not strictly greater than what they
// run, so pushing one to the channel could only strand it. Fail fast — before
// the zip/encrypt work — unless it is an idempotent republish of the exact
// same version (e.g. a CI retry).
const existing = await readManifest({ client, bucket }, channelManifestKey);
if (existing && existing.version !== version && !values.force) {
const currentCount = versionCount(existing.version);
const nextCount = versionCount(version);
if (currentCount !== null && nextCount !== null && nextCount <= currentCount) {
console.error(
`Refusing to publish ${version} to channel '${channel}': it does not ` +
`order above the current ${existing.version} and devices would ` +
"ignore it. Publish a higher-count build (git revert + republish), " +
"or pass --force if you really know better.",
);
process.exit(1);
}
}
let keyDir;
try {
// Decode the base64 PEM private key into a locked-down temp file: the CLI
// takes a key path, and base64 keeps the multi-line PEM to a single env
// line. Written inside the try — in a private, unpredictable mkdtemp dir —
// so the finally always wipes it, whatever fails below.
keyDir = mkdtempSync(join(tmpdir(), "ota-signing-"));
const keyPath = join(keyDir, "private.pem");
writeFileSync(
keyPath,
Buffer.from(
requireEnv("MOBILE_OTA_SIGNING_PRIVATE_KEY_B64"),
"base64",
).toString("utf8"),
{ mode: 0o600 },
);
// 1. Zip `dist/` (index.html at the root) and get its plaintext sha256.
const { checksum: plainChecksum } = capgo([
"bundle",
"zip",
appId,
"--path",
dist,
"--name",
zipName,
"--key-v2",
"--no-code-check",
"--json",
]);
// 2. Encrypt+sign. Emits the encrypted zip, the encrypted checksum and the
// ivSessionKey; the last two travel in the manifest for native verification.
const { checksum, ivSessionKey } = capgo([
"bundle",
"encrypt",
zipName,
plainChecksum,
"--key",
keyPath,
"--json",
]);
// 3. Upload the *encrypted* zip under the channel (see the header comment:
// channels never share bundles).
const body = readFileSync(encryptedZip);
const bundleKey = `${prefix}channels/${channel}/bundles/${version}.zip`;
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: bundleKey,
Body: body,
ContentType: "application/zip",
}),
);
// Public base URL the device uses to reach the bucket — kept separate from the
// S3 endpoint the script writes to. In dev the script uploads to
// objectstorage:9000 (compose network) while the emulator reads the manifest
// from localhost:8906 (adb reverse); in prod they are the CDN vs the bucket.
const publicBaseUrl = requireEnv("MOBILE_OTA_PUBLIC_BASE_URL").replace(/\/+$/, "");
const manifest = {
version,
url: `${publicBaseUrl}/channels/${channel}/bundles/${version}.zip`,
checksum,
sessionKey: ivSessionKey,
};
await writeManifest({ client, bucket }, channelManifestKey, manifest);
console.log(
`Published signed OTA bundle ${version} (${body.length} bytes) to ` +
`'${bucket}/${bundleKey}' and updated '${channelManifestKey}' ` +
`(channel '${channel}').`,
);
} finally {
for (const path of [zipName, encryptedZip]) {
rmSync(path, { force: true });
}
if (keyDir) {
rmSync(keyDir, { recursive: true, force: true });
}
}
+7
View File
@@ -10,6 +10,7 @@ import { initI18n } from "@/features/i18n/initI18n";
import { installThemeFavicons } from "@/features/providers/theme-favicons";
import { initSentry } from "@/features/sentry";
import { handle } from '@/features/utils/errors';
import { checkAndApplyOtaUpdate, notifyOtaAppReady } from "./features/native/ota";
// Tag the document on the Capacitor native app so the stylesheet can opt into
// mobile-only chrome (compact header, floating bottom bars) without each
@@ -68,6 +69,9 @@ export const bootstrap = async () => {
}
const config = resolveConfig(response?.data);
// Fire-and-forget: a pending update reloads the WebView once downloaded;
// until then the current bundle renders normally.
void checkAndApplyOtaUpdate(config.MOBILE_OTA_MANIFEST_URL);
initSentry(config);
initI18n(config);
installThemeFavicons(config.THEME_CONFIG.theme);
@@ -81,5 +85,8 @@ export const bootstrap = async () => {
handle(error);
container.innerHTML =
"<p>Something went wrong while starting the application. Please try again later.</p>";
return;
}
void notifyOtaAppReady();
};
@@ -59,4 +59,6 @@ export type ConfigRetrieve200 = {
readonly FRONTEND_FEEDBACK_WIDGET_CONFIG?: ConfigRetrieve200FRONTENDFEEDBACKWIDGETCONFIG;
/** Configuration of the Lagaufre widget */
readonly FRONTEND_LAGAUFRE_WIDGET_CONFIG?: ConfigRetrieve200FRONTENDLAGAUFREWIDGETCONFIG;
/** OTA channel manifest URL the mobile apps poll at startup; unset disables OTA updates */
readonly MOBILE_OTA_MANIFEST_URL?: string;
};
@@ -41,6 +41,8 @@ const API_CONFIG = {
api_url: "https://lagaufre.example.com",
path: "https://lagaufre.example.com/static/",
},
MOBILE_OTA_MANIFEST_URL:
"https://static.example.com/ota/channels/prod/manifest.json",
} as unknown as ConfigRetrieve200;
// The module keeps a warn-once registry, so each test imports a fresh copy.
@@ -55,6 +57,10 @@ describe("resolveConfig", () => {
it("uses API values first and normalizes languages to BCP 47", async () => {
vi.stubEnv("NEXT_PUBLIC_HELP_CENTER_URL", "https://deprecated.example.com");
vi.stubEnv(
"NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL",
"https://deprecated.example.com/manifest.json",
);
const { resolveConfig } = await importResolve();
const config = resolveConfig(API_CONFIG);
@@ -74,6 +80,9 @@ describe("resolveConfig", () => {
expect(config.HELP_CENTER_URL).toBe("https://help.example.com");
expect(config.FEEDBACK_WIDGET.channel).toBe("support");
expect(config.LAGAUFRE_WIDGET.api_url).toBe("https://lagaufre.example.com");
expect(config.MOBILE_OTA_MANIFEST_URL).toBe(
"https://static.example.com/ota/channels/prod/manifest.json",
);
});
it("falls back on deprecated env vars when the API is unreachable", async () => {
@@ -87,6 +96,10 @@ describe("resolveConfig", () => {
vi.stubEnv("NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE", "50");
vi.stubEnv("NEXT_PUBLIC_HELP_CENTER_URL", "https://help.example.com");
vi.stubEnv("NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL", "support");
vi.stubEnv(
"NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL",
"http://localhost:8906/messages-ota/channels/dev/manifest.json",
);
const { resolveConfig } = await importResolve();
const config = resolveConfig(undefined);
@@ -100,6 +113,9 @@ describe("resolveConfig", () => {
expect(config.MULTIPART_UPLOAD_CHUNK_SIZE_MB).toBe(50);
expect(config.HELP_CENTER_URL).toBe("https://help.example.com");
expect(config.FEEDBACK_WIDGET.channel).toBe("support");
expect(config.MOBILE_OTA_MANIFEST_URL).toBe(
"http://localhost:8906/messages-ota/channels/dev/manifest.json",
);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("NEXT_PUBLIC_THEME_CONFIG is deprecated"),
);
@@ -162,6 +178,7 @@ describe("resolveConfig", () => {
expect(config.MULTIPART_UPLOAD_CHUNK_SIZE_MB).toBe(100);
expect(config.HELP_CENTER_URL).toBeUndefined();
expect(config.FEEDBACK_WIDGET).toEqual({});
expect(config.MOBILE_OTA_MANIFEST_URL).toBeUndefined();
expect(config.RELEASE).toBe("NA");
});
@@ -285,6 +285,13 @@ export const resolveConfig = (api?: ConfigRetrieve200): AppConfig => {
"FRONTEND_HELP_CENTER_URL",
import.meta.env.NEXT_PUBLIC_HELP_CENTER_URL,
),
MOBILE_OTA_MANIFEST_URL:
api?.MOBILE_OTA_MANIFEST_URL ??
deprecatedEnv(
"NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL",
"MOBILE_OTA_MANIFEST_URL",
import.meta.env.NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL,
),
FEEDBACK_WIDGET: resolveFeedbackWidget(api),
LAGAUFRE_WIDGET: resolveLagaufreWidget(api),
};
@@ -0,0 +1,262 @@
/**
* The OTA client decides whether a fleet updates, holds or rolls back: these
* tests pin the guards (freshness, downgrade, boot-loop) that keep a bad or
* replayed manifest from breaking devices.
*
* The manifest URL is passed by the caller (resolved from /config), but the
* hot-reload skip still reads import.meta.env at call time, so every test
* stubs the env and imports a fresh module graph via `loadOta()`.
*/
import type { CapacitorHttp as CapacitorHttpType } from "@capacitor/core";
import type { CapacitorUpdater as CapacitorUpdaterType } from "@capgo/capacitor-updater";
vi.mock("@capacitor/core", () => ({
CapacitorHttp: { get: vi.fn() },
}));
vi.mock("@capgo/capacitor-updater", () => ({
CapacitorUpdater: {
notifyAppReady: vi.fn(),
current: vi.fn(),
getFailedUpdate: vi.fn(),
download: vi.fn(),
set: vi.fn(),
},
}));
vi.mock("./platform", () => ({
isNativePlatform: vi.fn(),
}));
const MANIFEST_URL = "http://ota.test/channels/dev/manifest.json";
type OtaTestContext = {
ota: typeof import("./ota");
http: { get: ReturnType<typeof vi.fn> };
updater: Record<
"notifyAppReady" | "current" | "getFailedUpdate" | "download" | "set",
ReturnType<typeof vi.fn>
>;
isNative: ReturnType<typeof vi.fn>;
};
const loadOta = async (): Promise<OtaTestContext> => {
vi.resetModules();
// Stub unconditionally: a developer's frontend.local (hot reload enabled) or
// shell env would otherwise leak into import.meta.env and flip these branches.
vi.stubEnv("MOBILE_DEV_SERVER_URL", "");
// Baked verification key: without it checkAndApplyOtaUpdate refuses to
// apply anything (see the key-less refusal test, which re-stubs it empty).
vi.stubEnv("MOBILE_OTA_SIGNING_PUBLIC_KEY_B64", "test-public-key");
// Re-import everything in the same fresh module graph so the mock instances
// observed here are the ones the ota module holds. SEQUENTIALLY: concurrent
// dynamic imports right after resetModules race and can instantiate a mocked
// module twice, silently splitting the test's instance from the ota one.
const ota = await import("./ota");
const { CapacitorHttp } = await import("@capacitor/core");
const { CapacitorUpdater } = await import("@capgo/capacitor-updater");
const { isNativePlatform } = await import("./platform");
const isNative = vi.mocked(isNativePlatform);
isNative.mockReturnValue(true);
return {
ota,
http: vi.mocked(CapacitorHttp as unknown as typeof CapacitorHttpType),
updater: vi.mocked(
CapacitorUpdater as unknown as typeof CapacitorUpdaterType,
) as unknown as OtaTestContext["updater"],
isNative,
};
};
/** Wire the standard happy-path plumbing, overridable per test. */
const primeUpdate = (
ctx: OtaTestContext,
{
current,
manifest,
bootFailed = null,
}: {
current: string;
manifest: { version: string; url?: string; checksum?: string; sessionKey?: string };
bootFailed?: string | null;
},
) => {
ctx.http.get.mockResolvedValue({ data: { url: "http://ota.test/bundle.zip", ...manifest } });
ctx.updater.current.mockResolvedValue({ bundle: { version: current } });
ctx.updater.getFailedUpdate.mockResolvedValue(
bootFailed ? { bundle: { version: bootFailed } } : null,
);
ctx.updater.download.mockResolvedValue({ id: "next-bundle" });
ctx.updater.set.mockResolvedValue(undefined);
};
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
// The boot-loop guard mirrors the plugin's failed-update record here; drop
// it so a blacklist written by one test never leaks into the next.
localStorage.clear();
// Restore the console spies installed by the error/guard tests so a silenced
// console never leaks into an unrelated test.
vi.restoreAllMocks();
});
describe("notifyOtaAppReady", () => {
it("confirms the running bundle on native", async () => {
const ctx = await loadOta();
await ctx.ota.notifyOtaAppReady();
expect(ctx.updater.notifyAppReady).toHaveBeenCalledOnce();
});
it("does nothing on the web", async () => {
const ctx = await loadOta();
ctx.isNative.mockReturnValue(false);
await ctx.ota.notifyOtaAppReady();
expect(ctx.updater.notifyAppReady).not.toHaveBeenCalled();
});
it("swallows plugin failures", async () => {
const ctx = await loadOta();
const error = new Error("bridge down");
ctx.updater.notifyAppReady.mockRejectedValue(error);
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(ctx.ota.notifyOtaAppReady()).resolves.toBeUndefined();
expect(consoleError).toHaveBeenCalledWith("OTA notifyAppReady failed", error);
});
});
describe("checkAndApplyOtaUpdate", () => {
it("does nothing on the web", async () => {
const ctx = await loadOta();
ctx.isNative.mockReturnValue(false);
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.http.get).not.toHaveBeenCalled();
});
it("does nothing when no manifest URL is configured", async () => {
const ctx = await loadOta();
await ctx.ota.checkAndApplyOtaUpdate(undefined);
expect(ctx.http.get).not.toHaveBeenCalled();
});
it("refuses a manifest URL when the build embeds no verification key", async () => {
const ctx = await loadOta();
vi.stubEnv("MOBILE_OTA_SIGNING_PUBLIC_KEY_B64", "");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.http.get).not.toHaveBeenCalled();
expect(consoleWarn).toHaveBeenCalledWith(
"OTA manifest URL configured but this build embeds no signing public " +
"key (MOBILE_OTA_SIGNING_PUBLIC_KEY_B64); skipping unverifiable update.",
);
});
it("skips a manifest advertising the running version", async () => {
const ctx = await loadOta();
primeUpdate(ctx, { current: "100-aaa", manifest: { version: "100-aaa" } });
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.updater.download).not.toHaveBeenCalled();
});
it("refuses a manifest with a lower count (downgrade/replay guard)", async () => {
const ctx = await loadOta();
primeUpdate(ctx, { current: "100-bbb", manifest: { version: "99-aaa" } });
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.updater.download).not.toHaveBeenCalled();
expect(consoleWarn).toHaveBeenCalledWith(
"OTA manifest 99-aaa is not newer than the running 100-bbb; skipping to avoid a downgrade.",
);
});
it("refuses an equal count from a diverged branch", async () => {
const ctx = await loadOta();
primeUpdate(ctx, { current: "100-bbb", manifest: { version: "100-aaa" } });
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.updater.download).not.toHaveBeenCalled();
expect(consoleWarn).toHaveBeenCalledWith(
"OTA manifest 100-aaa is not newer than the running 100-bbb; skipping to avoid a downgrade.",
);
});
it("refuses a version that previously failed to boot (boot-loop guard)", async () => {
const ctx = await loadOta();
primeUpdate(ctx, {
current: "100-aaa",
manifest: { version: "101-bbb" },
bootFailed: "101-bbb",
});
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.updater.download).not.toHaveBeenCalled();
expect(consoleWarn).toHaveBeenCalledWith(
"OTA 101-bbb previously failed to boot; skipping.",
);
});
it("keeps refusing a boot-failed version after the plugin record self-clears", async () => {
const ctx = await loadOta();
primeUpdate(ctx, {
current: "100-aaa",
manifest: { version: "101-bbb" },
bootFailed: "101-bbb",
});
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
// getFailedUpdate() clears the native record on read: from now on it
// resolves null and only the localStorage mirror remembers the failure.
ctx.updater.getFailedUpdate.mockResolvedValue(null);
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.updater.download).not.toHaveBeenCalled();
expect(consoleWarn).toHaveBeenCalledTimes(2);
});
it("still applies a newer version after an older one failed to boot", async () => {
const ctx = await loadOta();
primeUpdate(ctx, {
current: "100-aaa",
manifest: { version: "102-ccc" },
bootFailed: "101-bbb",
});
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.updater.set).toHaveBeenCalledWith({ id: "next-bundle" });
});
it("downloads and activates a strictly newer bundle", async () => {
const ctx = await loadOta();
primeUpdate(ctx, {
current: "100-aaa",
manifest: { version: "101-bbb", checksum: "chk", sessionKey: "sk" },
});
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.http.get).toHaveBeenCalledWith({ url: MANIFEST_URL });
expect(ctx.updater.download).toHaveBeenCalledWith({
url: "http://ota.test/bundle.zip",
version: "101-bbb",
// Both must reach the native layer or signature verification is skipped.
checksum: "chk",
sessionKey: "sk",
});
expect(ctx.updater.set).toHaveBeenCalledWith({ id: "next-bundle" });
});
it("falls back to a plain inequality check for non-hybrid ids", async () => {
const ctx = await loadOta();
// A fresh store install without MOBILE_OTA_BUILD_ID reports the literal
// "builtin": no ordering is possible, any different version applies.
primeUpdate(ctx, { current: "builtin", manifest: { version: "100-aaa" } });
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
expect(ctx.updater.set).toHaveBeenCalled();
});
it("leaves the current bundle untouched when the check fails", async () => {
const ctx = await loadOta();
const error = new Error("bucket unreachable");
ctx.http.get.mockRejectedValue(error);
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL)).resolves.toBeUndefined();
expect(ctx.updater.set).not.toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledWith("OTA update check failed", error);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { CapacitorHttp } from "@capacitor/core";
import { CapacitorUpdater } from "@capgo/capacitor-updater";
import { isNativePlatform } from "./platform";
/**
* Over-The-Air update of the JS bundle, driven entirely from S3 — no Capgo
* server is involved (see capacitor.config.ts, autoUpdate: false). The app
* reads a manifest published next to the bundles and, when it advertises a
* newer version than the one currently running, downloads the zip and swaps
* the WebView onto it.
*
* Bundles are encrypted+signed (Capgo v2, RSA+AES) zips of `dist/` uploaded to a
* public bucket; the manifest is `{ version, url, checksum, sessionKey }`, the
* last two feeding native signature verification. See the `ota-publish` target.
*/
type OtaManifest = {
version: string;
url: string;
// Both come from `capgo bundle encrypt` at publish time (see publish-ota.mjs).
// With signing on, `checksum` is the *encrypted* checksum and `sessionKey` is
// the RSA-wrapped AES session key; the native layer verifies both against the
// baked-in public key. They are absent only for legacy unsigned bundles.
checksum?: string;
sessionKey?: string;
};
/**
* Mirror of the plugin's "last failed update" record (a bundle rolled back for
* never calling notifyAppReady). The native record self-clears on first read,
* so it is copied here to keep the blacklist across launches. WebView storage
* is per-origin, not per-bundle, so it survives the rollback itself.
*/
const OTA_BOOT_FAILED_KEY = "ota-boot-failed-version";
/**
* Parse the monotonic ordering prefix of a hybrid `<count>-<sha>` bundle version
* (see docs/mobile.md, "Bundle versioning"). Returns null for ids without it —
* the literal `"builtin"`, or a manually pinned non-hybrid version — which then
* fall back to the plain inequality check (no ordering enforced).
*/
const versionCount = (version: string): number | null => {
const match = /^(\d+)-/.exec(version);
return match ? Number(match[1]) : null;
};
/**
* Confirm the running bundle booted successfully. Without this call the plugin
* assumes the update is broken and rolls back to the previous bundle on the
* next launch, so it must run as early as the app is functional.
*/
export const notifyOtaAppReady = async (): Promise<void> => {
if (!isNativePlatform()) {
return;
}
try {
await CapacitorUpdater.notifyAppReady();
} catch (error) {
console.error("OTA notifyAppReady failed", error);
}
};
/**
* Fetch the manifest and, if it points to a version other than the active
* bundle, download and activate it. `set()` reloads the WebView, so on success
* this never returns normally. Fire-and-forget at startup: failures are logged
* and leave the current bundle untouched.
*
* The manifest URL comes from the resolved app configuration (backend
* MOBILE_OTA_MANIFEST_URL, /config endpoint) so the followed channel can
* change without shipping a new native build; unset disables OTA.
*/
export const checkAndApplyOtaUpdate = async (
manifestUrl: string | undefined,
): Promise<void> => {
if (!isNativePlatform() || !manifestUrl) {
return;
}
// Hot reload session: the app is served by the Vite dev server (DEV) with
// MOBILE_DEV_SERVER_URL baked as server.url (capacitor.config.ts). Applying
// an OTA there would reload the WebView onto a downloaded bundle, killing
// the session. An embedded dev build (MOBILE_DEV_SERVER_URL unset) still
// exercises the full OTA chain.
if (import.meta.env.DEV && import.meta.env.MOBILE_DEV_SERVER_URL) {
return;
}
// The manifest URL is server-driven, so it can no longer prove at build time
// that the bundle-verification public key was baked in (capacitor.config.ts).
// Without that key the native layer would apply an unverified zip from the
// world-readable bucket — refuse instead: a key-less build is not OTA-capable.
if (!import.meta.env.MOBILE_OTA_SIGNING_PUBLIC_KEY_B64) {
console.warn(
"OTA manifest URL configured but this build embeds no signing public " +
"key (MOBILE_OTA_SIGNING_PUBLIC_KEY_B64); skipping unverifiable update.",
);
return;
}
try {
// Routed through the native HTTP layer: reaches the cleartext dev bucket
// and sidesteps WebView CORS.
const response = await CapacitorHttp.get({ url: manifestUrl });
const manifest = response.data as OtaManifest;
// CapacitorHttp resolves even on HTTP errors, so a 403/404 (manifest not
// published yet) lands here with an S3 error body instead of JSON. Fail
// fast with a clear log rather than deep inside download().
if (
typeof manifest?.version !== "string" ||
typeof manifest?.url !== "string"
) {
console.warn(
`OTA manifest at ${manifestUrl} is malformed or missing ` +
`(HTTP ${response.status}); skipping.`,
);
return;
}
const { bundle } = await CapacitorUpdater.current();
if (manifest.version === bundle.version) {
return;
}
// Downgrade/replay guard: only ever move forward. The hybrid version's
// leading count is monotonic, so a manifest whose count is not strictly
// greater than the running bundle's is an accidental old publish (or a
// replayed old bundle) — refuse it. Ids without a count prefix can't be
// ordered, so they fall through to the inequality check above and still
// apply (dev / non-hybrid builds).
const currentCount = versionCount(bundle.version);
const nextCount = versionCount(manifest.version);
if (currentCount !== null && nextCount !== null && nextCount <= currentCount) {
console.warn(
`OTA manifest ${manifest.version} is not newer than the running ` +
`${bundle.version}; skipping to avoid a downgrade.`,
);
return;
}
// Boot-loop guard: a bundle that failed to boot (never called
// notifyAppReady) was auto-reverted by the plugin, which records it as the
// last failed update. Re-applying it would just crash and revert again,
// forever — so refuse a version already known bad. Recovery is a *new*
// higher-count publish (see docs/mobile.md, "Rollback"). Unlike bundle
// statuses in `list()`, this record is boot-specific: a transient
// download/install failure never sets it, so those versions stay
// retryable.
const failed = await CapacitorUpdater.getFailedUpdate();
if (failed?.bundle.version) {
localStorage.setItem(OTA_BOOT_FAILED_KEY, failed.bundle.version);
}
if (localStorage.getItem(OTA_BOOT_FAILED_KEY) === manifest.version) {
console.warn(
`OTA ${manifest.version} previously failed to boot; skipping.`,
);
return;
}
const next = await CapacitorUpdater.download({
url: manifest.url,
version: manifest.version,
checksum: manifest.checksum,
sessionKey: manifest.sessionKey,
});
await CapacitorUpdater.set(next);
} catch (error) {
console.error("OTA update check failed", error);
}
};
+9
View File
@@ -30,6 +30,15 @@ interface ImportMetaEnv {
readonly NEXT_PUBLIC_SENTRY_DSN?: string;
/** @deprecated the frontend now uses the backend ENVIRONMENT */
readonly NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
/** @deprecated use the MOBILE_OTA_MANIFEST_URL backend setting */
readonly NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL?: string;
// Mobile hot reload: Vite dev server URL baked as the WebView's server.url
// (capacitor.config.ts). Exposed so ota.ts can skip OTA during such a session.
readonly MOBILE_DEV_SERVER_URL?: string;
// OTA bundle-verification public key, also baked natively at `cap sync`
// (capacitor.config.ts). Exposed so ota.ts can refuse a server-provided
// manifest URL on a build that embeds no key.
readonly MOBILE_OTA_SIGNING_PUBLIC_KEY_B64?: string;
}
interface ImportMeta {
+9 -1
View File
@@ -60,7 +60,15 @@ export default defineConfig({
// build-time env vars left are NEXT_PUBLIC_API_ORIGIN and the deprecated
// NEXT_PUBLIC_* fallbacks (see features/config/resolve.ts). envPrefix tells
// Vite which env vars to expose to client code at build time.
envPrefix: 'NEXT_PUBLIC_',
// MOBILE_DEV_SERVER_URL (exact-name "prefix") lets ota.ts detect a mobile
// hot reload session (see capacitor.config.ts) and skip the OTA check there.
// MOBILE_OTA_SIGNING_PUBLIC_KEY_B64 (public key, safe to inline) lets ota.ts
// refuse a server-provided manifest URL on a build that can't verify bundles.
envPrefix: [
'NEXT_PUBLIC_',
'MOBILE_DEV_SERVER_URL',
'MOBILE_OTA_SIGNING_PUBLIC_KEY_B64',
],
build: {
outDir: 'dist',
sourcemap: false,