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