From 94d079bc1b42aecac91e8e690506a9740cb46855 Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Thu, 2 Jul 2026 16:05:35 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(mobile)=20add=20self-hosted=20OTA=20u?= =?UTF-8?q?pdate=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - 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. --- Makefile | 39 +- docs/env.md | 38 ++ docs/mobile.md | 595 ++++++++++++++++++ env.d/development/backend.defaults | 6 + env.d/development/frontend.defaults | 50 ++ src/backend/core/api/openapi.json | 5 + src/backend/core/api/viewsets/config.py | 11 + .../core/management/commands/create_bucket.py | 31 + src/backend/core/tests/api/test_config.py | 5 + src/backend/messages/settings.py | 8 + src/frontend/capacitor.config.ts | 58 +- src/frontend/package.json | 3 + src/frontend/scripts/create-ota-bucket.mjs | 41 ++ src/frontend/scripts/generate-ota-keys.mjs | 31 + src/frontend/scripts/ota-lib.mjs | 128 ++++ src/frontend/scripts/publish-ota.mjs | 198 ++++++ src/frontend/src/bootstrap.tsx | 7 + .../api/gen/models/config_retrieve200.ts | 2 + .../src/features/config/resolve.test.ts | 17 + src/frontend/src/features/config/resolve.ts | 7 + src/frontend/src/features/native/ota.test.ts | 262 ++++++++ src/frontend/src/features/native/ota.ts | 170 +++++ src/frontend/src/vite-env.d.ts | 9 + src/frontend/vite.config.ts | 10 +- 24 files changed, 1725 insertions(+), 6 deletions(-) create mode 100644 docs/mobile.md create mode 100644 src/frontend/scripts/create-ota-bucket.mjs create mode 100644 src/frontend/scripts/generate-ota-keys.mjs create mode 100644 src/frontend/scripts/ota-lib.mjs create mode 100644 src/frontend/scripts/publish-ota.mjs create mode 100644 src/frontend/src/features/native/ota.test.ts create mode 100644 src/frontend/src/features/native/ota.ts diff --git a/Makefile b/Makefile index 5f7dfb75..1d08d90b 100644 --- a/Makefile +++ b/Makefile @@ -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 - 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 -, 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 diff --git a/docs/env.md b/docs/env.md index a27179b0..797ebb2e 100644 --- a/docs/env.md +++ b/docs/env.md @@ -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": }`. 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//manifest.json` + +`channels//bundles/.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//…`); 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` | `-` (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 diff --git a/docs/mobile.md b/docs/mobile.md new file mode 100644 index 00000000..c0536077 --- /dev/null +++ b/docs/mobile.md @@ -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 + `` 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:` 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 ``: 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//bundles/.zip`, and write + `channels//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//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 `-` 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//bundles/.zip` key) +is a **hybrid id**, `-` — e.g. `1234-a1b2c3d`: + +- `` = `git rev-list --count HEAD`, a **monotonic** commit count that + orders releases; +- `` = `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=-` + 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 `` 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 `-`, 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=` (`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://: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. diff --git a/env.d/development/backend.defaults b/env.d/development/backend.defaults index 461d3945..3cd3ce3b 100644 --- a/env.d/development/backend.defaults +++ b/env.d/development/backend.defaults @@ -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 diff --git a/env.d/development/frontend.defaults b/env.d/development/frontend.defaults index 9fb683be..ef37dcbe 100644 --- a/env.d/development/frontend.defaults +++ b/env.d/development/frontend.defaults @@ -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= + diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 893afee1..eaa1580b 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -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": [ diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index 465315ba..41a5a06d 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -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, + ), ) diff --git a/src/backend/core/management/commands/create_bucket.py b/src/backend/core/management/commands/create_bucket.py index 7dc83a18..8bc02ba4 100644 --- a/src/backend/core/management/commands/create_bucket.py +++ b/src/backend/core/management/commands/create_bucket.py @@ -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.") + ) diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index 176cee59..c28f6f02 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -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"]) diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 522bd7fd..6ae0f47f 100644 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -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 ) diff --git a/src/frontend/capacitor.config.ts b/src/frontend/capacitor.config.ts index 5f224d0b..a623064b 100644 --- a/src/frontend/capacitor.config.ts +++ b/src/frontend/capacitor.config.ts @@ -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 } : {}), + }, }, }; diff --git a/src/frontend/package.json b/src/frontend/package.json index 128e6ebd..063697dd 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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": { diff --git a/src/frontend/scripts/create-ota-bucket.mjs b/src/frontend/scripts/create-ota-bucket.mjs new file mode 100644 index 00000000..65021483 --- /dev/null +++ b/src/frontend/scripts/create-ota-bucket.mjs @@ -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.`); diff --git a/src/frontend/scripts/generate-ota-keys.mjs b/src/frontend/scripts/generate-ota-keys.mjs new file mode 100644 index 00000000..9a362c2e --- /dev/null +++ b/src/frontend/scripts/generate-ota-keys.mjs @@ -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`); diff --git a/src/frontend/scripts/ota-lib.mjs b/src/frontend/scripts/ota-lib.mjs new file mode 100644 index 00000000..dbd27519 --- /dev/null +++ b/src/frontend/scripts/ota-lib.mjs @@ -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//` + * 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 `-` 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", + }), + ); +}; diff --git a/src/frontend/scripts/publish-ota.mjs b/src/frontend/scripts/publish-ota.mjs new file mode 100644 index 00000000..0a07c3c3 --- /dev/null +++ b/src/frontend/scripts/publish-ota.mjs @@ -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//bundles/.zip` → +// write `channels//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 - id) from overwriting each other. +// +// Usage: node scripts/publish-ota.mjs --version [--channel ] +// [--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 --channel " + + "[--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 }); + } +} diff --git a/src/frontend/src/bootstrap.tsx b/src/frontend/src/bootstrap.tsx index 9213f650..67780930 100644 --- a/src/frontend/src/bootstrap.tsx +++ b/src/frontend/src/bootstrap.tsx @@ -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 = "

Something went wrong while starting the application. Please try again later.

"; + return; } + + void notifyOtaAppReady(); }; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200.ts b/src/frontend/src/features/api/gen/models/config_retrieve200.ts index 3b574e37..38ab93a0 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -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; }; diff --git a/src/frontend/src/features/config/resolve.test.ts b/src/frontend/src/features/config/resolve.test.ts index 3bac8251..25e435b0 100644 --- a/src/frontend/src/features/config/resolve.test.ts +++ b/src/frontend/src/features/config/resolve.test.ts @@ -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"); }); diff --git a/src/frontend/src/features/config/resolve.ts b/src/frontend/src/features/config/resolve.ts index e84e04b9..77fb5b63 100644 --- a/src/frontend/src/features/config/resolve.ts +++ b/src/frontend/src/features/config/resolve.ts @@ -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), }; diff --git a/src/frontend/src/features/native/ota.test.ts b/src/frontend/src/features/native/ota.test.ts new file mode 100644 index 00000000..5371ac9e --- /dev/null +++ b/src/frontend/src/features/native/ota.test.ts @@ -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 }; + updater: Record< + "notifyAppReady" | "current" | "getFailedUpdate" | "download" | "set", + ReturnType + >; + isNative: ReturnType; +}; + +const loadOta = async (): Promise => { + 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); + }); +}); diff --git a/src/frontend/src/features/native/ota.ts b/src/frontend/src/features/native/ota.ts new file mode 100644 index 00000000..d3300984 --- /dev/null +++ b/src/frontend/src/features/native/ota.ts @@ -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 `-` 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 => { + 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 => { + 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); + } +}; diff --git a/src/frontend/src/vite-env.d.ts b/src/frontend/src/vite-env.d.ts index 13fafdc6..41e527df 100644 --- a/src/frontend/src/vite-env.d.ts +++ b/src/frontend/src/vite-env.d.ts @@ -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 { diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index f231cf81..9a82a4bd 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -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,