Skip to content

Development#747

Open
jbpenrath wants to merge 6 commits into
mainfrom
development
Open

Development#747
jbpenrath wants to merge 6 commits into
mainfrom
development

Conversation

@jbpenrath

@jbpenrath jbpenrath commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Purpose

Intermediate branch

Summary by CodeRabbit

  • New Features

    • Added Capacitor-based mobile app support with deep-link OIDC login/logout, native file downloads, and safe-area-aware UI.
    • Introduced mobile OTA manifest support plus encrypted/signed OTA bundle publishing and channel manifests.
    • Added containerized mobile build/sync/run workflows for Android and iOS.
  • Bug Fixes

    • Improved session-bound CSRF handling across mobile/web authenticated requests.
    • Blocked release packaging when dev-only networking settings would be included.
  • Documentation

    • Expanded mobile and OTA environment/build guidance and added new mobile config documentation.
  • Tests

    • Added end-to-end and unit coverage for mobile auth, OTA flows, and CSRF behavior.

@jbpenrath jbpenrath self-assigned this Jul 8, 2026
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds Capacitor-based Android and iOS mobile apps with system-browser authentication, session-bound CSRF, native downloads, OTA publishing and updates, native project scaffolding, build tooling, configuration, documentation, and supporting PWA and Keycloak changes.

Changes

Mobile backend authentication and CSRF

Layer / File(s) Summary
OIDC handoff and session lifecycle
src/backend/core/authentication/*, src/backend/core/api/viewsets/mobile_auth.py, src/backend/core/urls.py, src/backend/messages/settings.py
Adds mobile OIDC deep-link handoff, PKCE-verified one-time session exchange, logout, throttling, and session-bound CSRF handling.
API contracts and validation
src/backend/core/api/serializers.py, src/backend/core/api/openapi.json, src/backend/core/tests/*, src/e2e/src/__tests__/thread-event.spec.ts
Exposes csrf_token and validates mobile authentication, upload CSRF, configuration, and e2e usage.
OTA and mobile build infrastructure
Layer / File(s) Summary
Build and publishing workflow
Makefile, compose.yaml, src/frontend/scripts/*, src/frontend/package.json, src/backend/core/management/commands/create_bucket.py
Adds mobile build/run targets, frontend-mobile container wiring, OTA key and bucket commands, encrypted bundle publishing, and public bucket policies.
Configuration and documentation
src/frontend/capacitor.config.ts, src/frontend/src/features/config/*, docs/*, env.d/development/*, src/frontend/vite*
Adds OTA manifest resolution, signing/build identifiers, environment defaults, and mobile architecture/build documentation.
Frontend native shell
Layer / File(s) Summary
Authentication and CSRF
src/frontend/src/features/native/{auth,auth-session,pkce,csrf}.ts, src/frontend/src/features/api/*, src/frontend/src/features/auth/index.tsx
Implements native PKCE login/logout, browser callback handling, persistent native CSRF storage, and web session-token caching.
OTA and downloads
src/frontend/src/features/native/{ota,download,use-native-download}.*, src/frontend/src/bootstrap.tsx, src/frontend/src/features/layouts/*
Adds guarded native OTA checks, native file download/share, and platform-specific download integration.
Native layout and invariants
src/frontend/src/styles/*, src/frontend/src/features/native/sso-invariants.test.ts
Adds safe-area styling, native viewport behavior, and cross-platform callback configuration checks.
Android and iOS projects
Layer / File(s) Summary
Android project
src/frontend/android/**
Adds Gradle configuration, Capacitor plugins, deep-link and file-provider manifest entries, safe-area injection, resources, icons, and tests.
iOS project
src/frontend/ios/**
Adds the Xcode project, Swift package dependencies, app delegate, browser authentication plugin, deep-link configuration, storyboards, and assets.
Supporting changes
Layer / File(s) Summary
Frontend assets and realm configuration
src/frontend/public/*, src/keycloak/realm.json
Updates PWA metadata, download translations, and ACR level-of-assurance mappings.

Estimated code review effort: 5 (Critical) | ~180 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MobileApp
  participant SystemBrowser
  participant OIDCBackend
  participant MobileSessionExchangeView
  MobileApp->>SystemBrowser: open OIDC URL with PKCE challenge
  SystemBrowser->>OIDCBackend: authenticate and return deep-link token
  OIDCBackend-->>MobileApp: deliver one-time token
  MobileApp->>MobileSessionExchangeView: POST token and verifier
  MobileSessionExchangeView-->>MobileApp: return CSRF token and session cookie
Loading
sequenceDiagram
  participant App
  participant OTAClient
  participant S3Manifest
  participant CapacitorUpdater
  App->>OTAClient: check manifest URL
  OTAClient->>S3Manifest: fetch manifest
  OTAClient->>OTAClient: validate version and boot-loop guards
  OTAClient->>CapacitorUpdater: download and activate bundle
Loading

Possibly related PRs

Suggested reviewers: NathanVss

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 74.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is generic and does not describe the main change in the pull request. Use a concise, specific title that summarizes the primary change, e.g. 'Add Capacitor mobile app support and OTA updates'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/env.md`:
- Line 379: Update the OTA environment variables section to explicitly mention
that NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL is a deprecated fallback for
MOBILE_OTA_MANIFEST_URL. Adjust the surrounding wording in the docs entry so it
is clear that MOBILE_OTA_MANIFEST_URL is the primary control while the
NEXT_PUBLIC_... variable is still honored for compatibility, and reference the
existing OTA env mapping row to keep the guidance easy to find.

In `@env.d/development/frontend.defaults`:
- Around line 1-85: The opening comment is outdated because this defaults file
now includes several build-time values beyond the API origin. Update the top
comment in frontend.defaults to say that NEXT_PUBLIC_API_ORIGIN is the only
required build-time variable, while keeping the rest of the defaults described
as optional or dev-only.

In `@src/frontend/android/app/build.gradle`:
- Around line 27-32: The release build in buildTypes currently disables
minification, so update the release configuration in the Android app Gradle
setup to enable shrinking/obfuscation for production builds. Adjust the
buildTypes.release block in the app build script so minify is turned on and keep
the existing proguardFiles setup, making sure any Capacitor-specific classes or
plugins remain covered by the existing rules.

In `@src/frontend/capacitor.config.ts`:
- Around line 46-67: Add an iOS release guard around the
`server.url`/`cleartext` dev-server config so shipped builds cannot inherit
`MOBILE_DEV_SERVER_URL`. In `capacitor.config.ts`, update the `config.server`
construction near `devServerUrl` to only apply `url` and `cleartext` for
non-release iOS builds (and keep Android’s existing release protection), using
the existing `MOBILE_DEV_SERVER_URL` and `MOBILE_ALLOW_CLEARTEXT_FOR_DEV` checks
to prevent production `CapacitorConfig` from pointing at the dev server or
enabling cleartext.

In `@src/frontend/ios/App/App/WebAuthSessionPlugin.swift`:
- Around line 76-80: The PresentationAnchorProvider.presentationAnchor(for:)
implementation is returning a bare ASPresentationAnchor(), which can be invalid
for scene-based apps. Update this method to return the active app key window
from the connected scene instead, using the existing PresentationAnchorProvider
type as the lookup point and keeping the
ASWebAuthenticationPresentationContextProviding contract intact.
- Around line 52-59: The `WebAuthSessionPlugin` completion handler is clearing
`session` from `ASWebAuthenticationSession`’s private callback queue, which
races with the main-queue reads/writes in `authenticate` and `session`. Update
the completion block in `WebAuthSessionPlugin.swift` so the `self?.session =
nil` cleanup is dispatched onto the main queue, keeping all `session` access
serialized on the same queue. Use the existing `session` property and the
`authenticate` flow as the reference points when making the change.
- Around line 19-74: Add a deinitializer to WebAuthSessionPlugin that cancels
any active ASWebAuthenticationSession before the plugin is released. Update the
WebAuthSessionPlugin type by introducing deinit alongside start(_:) so that
self.session is safely cancelled and cleared when the plugin is deallocated,
preventing orphaned sessions and satisfying the required_deinit lint rule.

In `@src/frontend/src/bootstrap.tsx`:
- Around line 88-91: Move the notifyOtaAppReady() call out of bootstrap.tsx so
it is no longer triggered immediately after createRoot(...).render(...), since
that can run before the app has actually committed. Instead, call
notifyOtaAppReady() from the mounted root shell in __root.tsx inside its
useEffect, using the root component’s mount lifecycle to signal readiness after
the first committed render.

In `@src/frontend/src/features/native/auth.ts`:
- Around line 60-61: The exchange response handling in auth.ts should validate
csrfToken before persisting it, since destructuring ExchangeResponse can yield
undefined and setNativeCsrfToken would otherwise store an invalid value. Update
the login flow around the response.json() parsing and setNativeCsrfToken call to
only save a real token when csrf_token is present, and handle the missing-token
case explicitly instead of writing it to storage.

In `@src/frontend/src/features/native/download.ts`:
- Around line 52-67: The download flow in the native share path leaves the
cached file behind after Filesystem.writeFile and Share.share. Update the logic
in the download/share function to ensure the temporary file is always cleaned up
by removing it with Filesystem.deleteFile after sharing completes or fails,
while still preserving the existing cancellation handling in the Share.share
error path. Use the existing safeFilename, uri, and Share.share flow to place
the cleanup in a try/finally-style structure so the cache entry does not
accumulate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6f0bdbb5-322e-46c9-823f-fcd559e32e27

📥 Commits

Reviewing files that changed from the base of the PR and between 983df0f and 02c35ca.

⛔ Files ignored due to path filters (119)
  • src/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-ldpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-hdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-ldpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-mdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-xhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-night/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-ldpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-hdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-ldpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-mdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-xhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-ldpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/gradle/wrapper/gradle-wrapper.jar is excluded by !**/*.jar
  • src/frontend/assets/icon-dark.png is excluded by !**/*.png
  • src/frontend/assets/icon-foreground.png is excluded by !**/*.png
  • src/frontend/assets/icon-only.png is excluded by !**/*.png
  • src/frontend/assets/splash-dark.png is excluded by !**/*.png
  • src/frontend/assets/splash.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany-dark.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany-dark.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany-dark.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png is excluded by !**/*.png
  • src/frontend/package-lock.json is excluded by !**/package-lock.json
  • src/frontend/public/images/anct/app-icon-2x.png is excluded by !**/*.png
  • src/frontend/public/images/anct/app-icon.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-144-144.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-192-192.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-48-48.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-512-512.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-72-72.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-96-96.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-144x144.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-192x192.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-48x48.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-512x512.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-72x72.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-96x96.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/100.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/1024.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/114.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/120.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/128.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/144.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/152.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/16.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/167.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/180.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/192.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/20.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/256.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/29.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/32.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/40.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/50.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/512.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/57.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/58.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/60.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/64.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/72.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/76.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/80.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/87.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-120.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-128.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-144.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-152.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-180.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-192.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-384.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-512.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-72.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-96.png is excluded by !**/*.png
  • src/frontend/src/features/api/gen/models/config_retrieve200.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/user_with_abilities.ts is excluded by !**/gen/**
📒 Files selected for processing (113)
  • Makefile
  • compose.yaml
  • docs/env.md
  • docs/mobile.md
  • env.d/development/backend.defaults
  • env.d/development/frontend.defaults
  • src/backend/core/api/openapi.json
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/config.py
  • src/backend/core/api/viewsets/mobile_auth.py
  • src/backend/core/authentication/views.py
  • src/backend/core/management/commands/create_bucket.py
  • src/backend/core/tests/api/test_attachments.py
  • src/backend/core/tests/api/test_config.py
  • src/backend/core/tests/api/test_users.py
  • src/backend/core/tests/authentication/test_mobile_auth.py
  • src/backend/core/urls.py
  • src/backend/messages/settings.py
  • src/e2e/src/__tests__/thread-event.spec.ts
  • src/frontend/Dockerfile
  • src/frontend/android/.gitignore
  • src/frontend/android/app/.gitignore
  • src/frontend/android/app/build.gradle
  • src/frontend/android/app/capacitor.build.gradle
  • src/frontend/android/app/proguard-rules.pro
  • src/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
  • src/frontend/android/app/src/main/AndroidManifest.xml
  • src/frontend/android/app/src/main/java/local/suitenumerique/messages/MainActivity.java
  • src/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
  • src/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml
  • src/frontend/android/app/src/main/res/layout/activity_main.xml
  • src/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  • src/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  • src/frontend/android/app/src/main/res/values-night/colors.xml
  • src/frontend/android/app/src/main/res/values/colors.xml
  • src/frontend/android/app/src/main/res/values/strings.xml
  • src/frontend/android/app/src/main/res/values/styles.xml
  • src/frontend/android/app/src/main/res/xml/file_paths.xml
  • src/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java
  • src/frontend/android/build.gradle
  • src/frontend/android/capacitor.settings.gradle
  • src/frontend/android/gradle.properties
  • src/frontend/android/gradle/wrapper/gradle-wrapper.properties
  • src/frontend/android/gradlew
  • src/frontend/android/gradlew.bat
  • src/frontend/android/settings.gradle
  • src/frontend/android/variables.gradle
  • src/frontend/capacitor.config.ts
  • src/frontend/eslint.config.mjs
  • src/frontend/ios/.gitignore
  • src/frontend/ios/App/App.xcodeproj/project.pbxproj
  • src/frontend/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  • src/frontend/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  • src/frontend/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme
  • src/frontend/ios/App/App/AppDelegate.swift
  • src/frontend/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
  • src/frontend/ios/App/App/Assets.xcassets/Contents.json
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
  • src/frontend/ios/App/App/Base.lproj/LaunchScreen.storyboard
  • src/frontend/ios/App/App/Base.lproj/Main.storyboard
  • src/frontend/ios/App/App/Info.plist
  • src/frontend/ios/App/App/MainViewController.swift
  • src/frontend/ios/App/App/WebAuthSessionPlugin.swift
  • src/frontend/ios/App/CapApp-SPM/.gitignore
  • src/frontend/ios/App/CapApp-SPM/Package.swift
  • src/frontend/ios/App/CapApp-SPM/README.md
  • src/frontend/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift
  • src/frontend/ios/debug.xcconfig
  • src/frontend/package.json
  • src/frontend/public/assets/icons/icon-128.webp
  • src/frontend/public/assets/icons/icon-192.webp
  • src/frontend/public/assets/icons/icon-256.webp
  • src/frontend/public/assets/icons/icon-48.webp
  • src/frontend/public/assets/icons/icon-512.webp
  • src/frontend/public/assets/icons/icon-72.webp
  • src/frontend/public/assets/icons/icon-96.webp
  • src/frontend/public/browserconfig.xml
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/public/manifest.json
  • src/frontend/scripts/create-ota-bucket.mjs
  • src/frontend/scripts/generate-ota-keys.mjs
  • src/frontend/scripts/ota-lib.mjs
  • src/frontend/scripts/publish-ota.mjs
  • src/frontend/src/bootstrap.tsx
  • src/frontend/src/features/api/csrf.test.ts
  • src/frontend/src/features/api/csrf.ts
  • src/frontend/src/features/api/utils.ts
  • src/frontend/src/features/auth/index.tsx
  • src/frontend/src/features/config/resolve.test.ts
  • src/frontend/src/features/config/resolve.ts
  • src/frontend/src/features/layouts/components/main/header/_index.scss
  • src/frontend/src/features/layouts/components/thread-view/components/attachment-preview-modal/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-attachment-list/attachment-item.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-actions.tsx
  • src/frontend/src/features/native/auth-session.ts
  • src/frontend/src/features/native/auth.ts
  • src/frontend/src/features/native/csrf.test.ts
  • src/frontend/src/features/native/csrf.ts
  • src/frontend/src/features/native/download.test.ts
  • src/frontend/src/features/native/download.ts
  • src/frontend/src/features/native/ota.test.ts
  • src/frontend/src/features/native/ota.ts
  • src/frontend/src/features/native/pkce.test.ts
  • src/frontend/src/features/native/pkce.ts
  • src/frontend/src/features/native/platform.ts
  • src/frontend/src/features/native/sso-invariants.test.ts
  • src/frontend/src/features/native/use-native-download.tsx
  • src/frontend/src/styles/globals.scss
  • src/frontend/src/styles/views/domain/_index.scss
  • src/frontend/src/vite-env.d.ts
  • src/frontend/vite.config.ts
  • src/keycloak/realm.json

Comment thread docs/env.md
Comment on lines 1 to +85
# The API origin is the only build-time variable left: it is needed to reach
# the backend /config endpoint that provides the rest of the configuration.
NEXT_PUBLIC_API_ORIGIN=http://localhost:8901
NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL=
NEXT_PUBLIC_FEEDBACK_WIDGET_PATH=
NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL=
NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL=
NEXT_PUBLIC_HELP_CENTER_URL=
NEXT_PUBLIC_LANGUAGES=[["en-US","English"],["fr-FR","Français"],["nl-NL","Nederlands"]]
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
## Chunk size for multipart upload in MB
NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE=100

## Sentry
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_ENVIRONMENT=

## Theme Customization
NEXT_PUBLIC_THEME_CONFIG='{
"theme": "white-label"
}'

## Mobile app build identity (Capacitor). Neutral placeholder shared by the repo;
## read by `cap sync` (container) and by the native builds (host: gradle / Xcode).
## An organisation publishing to the stores overrides it — in BOTH contexts — with
## its own signed bundle id, which never lands in this open-source repo.
MOBILE_APP_ID=local.suitenumerique.messages

## Mobile hot reload (Capacitor). Baked as the WebView's server.url at
## `cap sync` (capacitor.config.ts): the app loads the JS straight from the
## Vite dev server — changes apply through HMR, no rebuild/reinstall. The
## device reaches localhost:8900 via adb reverse (Android) or the simulator
## 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=

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the opening comment to match the new defaults.

The file now defines multiple build-time values (NEXT_PUBLIC_*, mobile, and OTA defaults), so “the only build-time variable left” is no longer accurate. Reword it to say API origin is the only required build-time value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@env.d/development/frontend.defaults` around lines 1 - 85, The opening comment
is outdated because this defaults file now includes several build-time values
beyond the API origin. Update the top comment in frontend.defaults to say that
NEXT_PUBLIC_API_ORIGIN is the only required build-time variable, while keeping
the rest of the defaults described as optional or dev-only.

Comment on lines +27 to +32
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider enabling minification for release builds.

minifyEnabled false ships the full native code unobfuscated. For a Capacitor app the native surface is small, so the risk is low, but enabling shrinking with proper proguard rules would reduce APK size and provide minimal obfuscation. This is a reasonable scaffold default; revisit before production hardening.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/android/app/build.gradle` around lines 27 - 32, The release
build in buildTypes currently disables minification, so update the release
configuration in the Android app Gradle setup to enable shrinking/obfuscation
for production builds. Adjust the buildTypes.release block in the app build
script so minify is turned on and keep the existing proguardFiles setup, making
sure any Capacitor-specific classes or plugins remain covered by the existing
rules.

Comment on lines +46 to +67
const devServerUrl = process.env.MOBILE_DEV_SERVER_URL;

const config: CapacitorConfig = {
// Build-time app identity. The repo ships a neutral placeholder; an
// organisation publishing to the stores overrides it with its own signed
// bundle id via the MOBILE_APP_ID env var (read here by `cap sync`, and by
// the native builds — see android/app/build.gradle and the iOS pbxproj).
appId: process.env.MOBILE_APP_ID ?? "local.suitenumerique.messages",
appName: "Messages",
webDir: "dist",
server: {
// 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 } : {}),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for iOS build guards against MOBILE_DEV_SERVER_URL.

# Check iOS pbxproj and build scripts for dev server URL guards
rg -n "MOBILE_DEV_SERVER_URL" src/frontend/ios/ 2>/dev/null

# Check for any build phase scripts that validate env vars
rg -n "MOBILE_DEV_SERVER_URL\|MOBILE_ALLOW_CLEARTEXT" src/frontend/ios/ 2>/dev/null

# Also check the Android guard for reference
rg -n "MOBILE_DEV_SERVER_URL" src/frontend/android/app/build.gradle 2>/dev/null

Repository: suitenumerique/messages

Length of output: 243


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the frontend tree and locate capacitor / iOS build files.
git ls-files src/frontend | sed -n '1,250p'

printf '\n--- capacitor.config.ts ---\n'
cat -n src/frontend/capacitor.config.ts | sed -n '1,220p'

printf '\n--- iOS-related files mentioning env vars ---\n'
rg -n "MOBILE_DEV_SERVER_URL|MOBILE_ALLOW_CLEARTEXT|cleartext|server\.url" src/frontend -g '!**/dist/**' -g '!**/build/**' || true

printf '\n--- Android guard reference ---\n'
rg -n "MOBILE_DEV_SERVER_URL|MOBILE_ALLOW_CLEARTEXT|release|cleartext" src/frontend/android/app/build.gradle || true

printf '\n--- iOS project/build scripts ---\n'
fd -a '.*(pbxproj|xcconfig|sh|swift|m|mm)$' src/frontend/ios 2>/dev/null | sed -n '1,200p' || true

Repository: suitenumerique/messages

Length of output: 24295


Add an iOS release guard for MOBILE_DEV_SERVER_URL. Android blocks release builds, but iOS can still bake a dev server.url and cleartext into the shipped config, which would make a production build load from the dev server and allow cleartext traffic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/capacitor.config.ts` around lines 46 - 67, Add an iOS release
guard around the `server.url`/`cleartext` dev-server config so shipped builds
cannot inherit `MOBILE_DEV_SERVER_URL`. In `capacitor.config.ts`, update the
`config.server` construction near `devServerUrl` to only apply `url` and
`cleartext` for non-release iOS builds (and keep Android’s existing release
protection), using the existing `MOBILE_DEV_SERVER_URL` and
`MOBILE_ALLOW_CLEARTEXT_FOR_DEV` checks to prevent production `CapacitorConfig`
from pointing at the dev server or enabling cleartext.

Comment on lines +19 to +74
public class WebAuthSessionPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "WebAuthSessionPlugin"
public let jsName = "WebAuthSession"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise)
]

private var session: ASWebAuthenticationSession?
private let presentationContextProvider = PresentationAnchorProvider()

@objc func start(_ call: CAPPluginCall) {
guard
let urlString = call.getString("url"),
let url = URL(string: urlString),
let callbackScheme = call.getString("callbackScheme")
else {
call.reject("url and callbackScheme are required")
return
}

DispatchQueue.main.async {
// Overlapping calls (e.g. a double tap on the login button) must
// not overwrite self.session: the first completion handler would
// drop the only strong reference to the newer, still-presented
// session and kill it with no error surfaced to JS.
if self.session != nil {
call.reject("An authentication session is already in progress")
return
}

let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: callbackScheme
) { [weak self] callbackURL, error in
if let callbackURL {
call.resolve(["callbackUrl": callbackURL.absoluteString])
} else {
call.reject(error?.localizedDescription ?? "Authentication was cancelled")
}
self?.session = nil
}
session.presentationContextProvider = self.presentationContextProvider
// Sharing persistent cookies with Safari is what provides the
// cross-app SSO: never switch this session to ephemeral mode.
session.prefersEphemeralWebBrowserSession = false
self.session = session
// If presentation fails the completion handler never runs; the
// session must be released here or every later login would be
// rejected as "already in progress".
if !session.start() {
self.session = nil
call.reject("Failed to start the authentication session")
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add deinit to cancel any in-flight authentication session.

If the plugin is deallocated while a session is active, the ASWebAuthenticationSession may continue running orphaned. SwiftLint also flags the missing deinit (required_deinit).

♻️ Add deinit to cancel session on dealloc
     private var session: ASWebAuthenticationSession?
     private let presentationContextProvider = PresentationAnchorProvider()

+    deinit {
+        session?.cancel()
+    }
+
     `@objc` func start(_ call: CAPPluginCall) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public class WebAuthSessionPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "WebAuthSessionPlugin"
public let jsName = "WebAuthSession"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise)
]
private var session: ASWebAuthenticationSession?
private let presentationContextProvider = PresentationAnchorProvider()
@objc func start(_ call: CAPPluginCall) {
guard
let urlString = call.getString("url"),
let url = URL(string: urlString),
let callbackScheme = call.getString("callbackScheme")
else {
call.reject("url and callbackScheme are required")
return
}
DispatchQueue.main.async {
// Overlapping calls (e.g. a double tap on the login button) must
// not overwrite self.session: the first completion handler would
// drop the only strong reference to the newer, still-presented
// session and kill it with no error surfaced to JS.
if self.session != nil {
call.reject("An authentication session is already in progress")
return
}
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: callbackScheme
) { [weak self] callbackURL, error in
if let callbackURL {
call.resolve(["callbackUrl": callbackURL.absoluteString])
} else {
call.reject(error?.localizedDescription ?? "Authentication was cancelled")
}
self?.session = nil
}
session.presentationContextProvider = self.presentationContextProvider
// Sharing persistent cookies with Safari is what provides the
// cross-app SSO: never switch this session to ephemeral mode.
session.prefersEphemeralWebBrowserSession = false
self.session = session
// If presentation fails the completion handler never runs; the
// session must be released here or every later login would be
// rejected as "already in progress".
if !session.start() {
self.session = nil
call.reject("Failed to start the authentication session")
}
}
}
}
public class WebAuthSessionPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "WebAuthSessionPlugin"
public let jsName = "WebAuthSession"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise)
]
private var session: ASWebAuthenticationSession?
private let presentationContextProvider = PresentationAnchorProvider()
deinit {
session?.cancel()
}
`@objc` func start(_ call: CAPPluginCall) {
guard
let urlString = call.getString("url"),
let url = URL(string: urlString),
let callbackScheme = call.getString("callbackScheme")
else {
call.reject("url and callbackScheme are required")
return
}
DispatchQueue.main.async {
// Overlapping calls (e.g. a double tap on the login button) must
// not overwrite self.session: the first completion handler would
// drop the only strong reference to the newer, still-presented
// session and kill it with no error surfaced to JS.
if self.session != nil {
call.reject("An authentication session is already in progress")
return
}
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: callbackScheme
) { [weak self] callbackURL, error in
if let callbackURL {
call.resolve(["callbackUrl": callbackURL.absoluteString])
} else {
call.reject(error?.localizedDescription ?? "Authentication was cancelled")
}
self?.session = nil
}
session.presentationContextProvider = self.presentationContextProvider
// Sharing persistent cookies with Safari is what provides the
// cross-app SSO: never switch this session to ephemeral mode.
session.prefersEphemeralWebBrowserSession = false
self.session = session
// If presentation fails the completion handler never runs; the
// session must be released here or every later login would be
// rejected as "already in progress".
if !session.start() {
self.session = nil
call.reject("Failed to start the authentication session")
}
}
}
}
🧰 Tools
🪛 SwiftLint (0.65.0)

[Warning] 19-19: Classes should have an explicit deinit method

(required_deinit)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/ios/App/App/WebAuthSessionPlugin.swift` around lines 19 - 74,
Add a deinitializer to WebAuthSessionPlugin that cancels any active
ASWebAuthenticationSession before the plugin is released. Update the
WebAuthSessionPlugin type by introducing deinit alongside start(_:) so that
self.session is safely cancelled and cleared when the plugin is deallocated,
preventing orphaned sessions and satisfying the required_deinit lint rule.

Source: Linters/SAST tools

Comment on lines +52 to +59
) { [weak self] callbackURL, error in
if let callbackURL {
call.resolve(["callbackUrl": callbackURL.absoluteString])
} else {
call.reject(error?.localizedDescription ?? "Authentication was cancelled")
}
self?.session = nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Data race on self.session — completion handler writes from a private queue.

ASWebAuthenticationSession calls its completion handler on a private dispatch queue (per Apple's documentation), but self.session is read and written on the main queue (lines 44, 64, 69). The self?.session = nil assignment on line 58 races with main-queue access. If the nil write is lost, self.session will never clear and every subsequent login will be rejected as "already in progress" — requiring an app restart.

🔒 Fix: dispatch session cleanup to the main queue
             ) { [weak self] callbackURL, error in
                 if let callbackURL {
                     call.resolve(["callbackUrl": callbackURL.absoluteString])
                 } else {
                     call.reject(error?.localizedDescription ?? "Authentication was cancelled")
                 }
-                self?.session = nil
+                DispatchQueue.main.async {
+                    self?.session = nil
+                }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
) { [weak self] callbackURL, error in
if let callbackURL {
call.resolve(["callbackUrl": callbackURL.absoluteString])
} else {
call.reject(error?.localizedDescription ?? "Authentication was cancelled")
}
self?.session = nil
}
) { [weak self] callbackURL, error in
if let callbackURL {
call.resolve(["callbackUrl": callbackURL.absoluteString])
} else {
call.reject(error?.localizedDescription ?? "Authentication was cancelled")
}
DispatchQueue.main.async {
self?.session = nil
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/ios/App/App/WebAuthSessionPlugin.swift` around lines 52 - 59,
The `WebAuthSessionPlugin` completion handler is clearing `session` from
`ASWebAuthenticationSession`’s private callback queue, which races with the
main-queue reads/writes in `authenticate` and `session`. Update the completion
block in `WebAuthSessionPlugin.swift` so the `self?.session = nil` cleanup is
dispatched onto the main queue, keeping all `session` access serialized on the
same queue. Use the existing `session` property and the `authenticate` flow as
the reference points when making the change.

Comment on lines +76 to +80
private class PresentationAnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
ASPresentationAnchor()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Return the app's key window instead of a bare ASPresentationAnchor().

ASPresentationAnchor() creates a new UIWindow with no scene and a zero frame. On iOS 15+ (the deployment target) with scene-based apps, this may not provide a valid presentation context — particularly on iPad where TARGETED_DEVICE_FAMILY includes 2. Returning the active key window is the recommended pattern.

♻️ Return the active key window from the connected scene
 private class PresentationAnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
+
+    deinit {}
+
     func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
-        ASPresentationAnchor()
+        let scenes = UIApplication.shared.connectedScenes
+            .compactMap { $0 as? UIWindowScene }
+        let activeScene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
+        return activeScene?.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor()
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private class PresentationAnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
ASPresentationAnchor()
}
}
private class PresentationAnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
deinit {}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
let activeScene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
return activeScene?.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor()
}
}
🧰 Tools
🪛 SwiftLint (0.65.0)

[Warning] 76-76: Classes should have an explicit deinit method

(required_deinit)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/ios/App/App/WebAuthSessionPlugin.swift` around lines 76 - 80,
The PresentationAnchorProvider.presentationAnchor(for:) implementation is
returning a bare ASPresentationAnchor(), which can be invalid for scene-based
apps. Update this method to return the active app key window from the connected
scene instead, using the existing PresentationAnchorProvider type as the lookup
point and keeping the ASWebAuthenticationPresentationContextProviding contract
intact.

Comment on lines +88 to +91
return;
}

void notifyOtaAppReady();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== bootstrap.tsx outline ==\n'
ast-grep outline src/frontend/src/bootstrap.tsx --view expanded || true

printf '\n== bootstrap.tsx around target lines ==\n'
sed -n '1,180p' src/frontend/src/bootstrap.tsx

printf '\n== notifyOtaAppReady references ==\n'
rg -n "notifyOtaAppReady|otaAppReady|OTA" src/frontend/src -S

printf '\n== files containing notifyOtaAppReady definition ==\n'
rg -n "function notifyOtaAppReady|const notifyOtaAppReady|export .*notifyOtaAppReady" src/frontend/src -S

Repository: suitenumerique/messages

Length of output: 10193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== ota.ts ==\n'
sed -n '1,240p' src/frontend/src/features/native/ota.ts

printf '\n== ota.test.ts relevant sections ==\n'
sed -n '1,340p' src/frontend/src/features/native/ota.test.ts

printf '\n== bootstrap error handling / error boundary references ==\n'
rg -n "ErrorBoundary|handle\\(|notifyOtaAppReady|boot|rollback|OTA_BOOT_FAILED_KEY" src/frontend/src -S

Repository: suitenumerique/messages

Length of output: 28907


🌐 Web query:

React 18 createRoot render synchronous commit error handling createRoot().render returns before commit

💡 Result:

In React 18 and later, calling root.render is not guaranteed to be fully synchronous [1]. Unlike the legacy ReactDOM.render method, root.render may schedule the render to occur in a future microtask [1]. Consequently, code executing immediately after root.render cannot rely on the DOM being fully updated or committed [2]. If you require synchronous execution—for example, when you must ensure the DOM nodes are present before subsequent JavaScript runs—you can wrap the render call in flushSync from react-dom [1][3][2]: import { flushSync } from 'react-dom'; import { createRoot } from 'react-dom/client'; const root = createRoot(document.getElementById('root')); flushSync( => { root.render(); }); // The DOM is now updated and ready for interaction. Regarding error handling, React 18 introduced the onRecoverableError option for createRoot and hydrateRoot to help capture and report errors that occur during rendering or hydration that do not crash the application [4][5]. For errors that do occur during rendering, standard React error boundaries remain the primary mechanism for catching and handling them in the component tree [4]. Recoverable errors typically occur when React encounters an issue during a synchronous "recovery" render (often triggered internally during concurrent updates) or hydration mismatches [4]. React uses reportError or console.error by default if you do not provide a custom onRecoverableError callback [4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== root route / app shell outline ==\n'
ast-grep outline src/frontend/src/routes/__root.tsx --view expanded || true

printf '\n== root route file ==\n'
sed -n '1,220p' src/frontend/src/routes/__root.tsx

printf '\n== RouterProvider usage ==\n'
rg -n "RouterProvider|useEffect\\(|notifyOtaAppReady" src/frontend/src -S

Repository: suitenumerique/messages

Length of output: 15098


Move notifyOtaAppReady() into the mounted root shell. createRoot(...).render(...) can return before the tree commits, so this can bless an OTA bundle before the app is actually mounted. Call it from src/frontend/src/routes/__root.tsx’s useEffect instead so “ready” lines up with the first committed render.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/bootstrap.tsx` around lines 88 - 91, Move the
notifyOtaAppReady() call out of bootstrap.tsx so it is no longer triggered
immediately after createRoot(...).render(...), since that can run before the app
has actually committed. Instead, call notifyOtaAppReady() from the mounted root
shell in __root.tsx inside its useEffect, using the root component’s mount
lifecycle to signal readiness after the first committed render.

Comment on lines +60 to +61
const { csrf_token: csrfToken } = (await response.json()) as ExchangeResponse;
setNativeCsrfToken(csrfToken);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate csrfToken before storing it.

If the exchange response omits csrf_token, the destructured value is undefined, which setNativeCsrfToken stores as the string "undefined" in localStorage (since undefined !== null). This causes all subsequent CSRF-protected requests to fail with 403, leaving the user stuck after a seemingly successful login.

🛡️ Proposed fix
     const { csrf_token: csrfToken } = (await response.json()) as ExchangeResponse;
+    if (!csrfToken) {
+      throw new Error("Mobile session exchange did not return a CSRF token.");
+    }
     setNativeCsrfToken(csrfToken);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { csrf_token: csrfToken } = (await response.json()) as ExchangeResponse;
setNativeCsrfToken(csrfToken);
const { csrf_token: csrfToken } = (await response.json()) as ExchangeResponse;
if (!csrfToken) {
throw new Error("Mobile session exchange did not return a CSRF token.");
}
setNativeCsrfToken(csrfToken);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/features/native/auth.ts` around lines 60 - 61, The exchange
response handling in auth.ts should validate csrfToken before persisting it,
since destructuring ExchangeResponse can yield undefined and setNativeCsrfToken
would otherwise store an invalid value. Update the login flow around the
response.json() parsing and setNativeCsrfToken call to only save a real token
when csrf_token is present, and handle the missing-token case explicitly instead
of writing it to storage.

Comment on lines +52 to +67
const { uri } = await Filesystem.writeFile({
path: safeFilename,
data: response.data as string,
directory: Directory.Cache,
});

try {
await Share.share({ url: uri, title: safeFilename });
} catch (error) {
// Dismissing the share sheet rejects too: the file was downloaded, the
// user just changed their mind — not a failure to report.
if (error instanceof Error && /cancel/i.test(error.message)) {
return;
}
throw error;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Clean up cached file after sharing (or on share failure).

Filesystem.writeFile writes to Directory.Cache but the file is never deleted — not after a successful share, nor after a non-cancellation error from Share.share. Each download accumulates a cached file until the OS reclaims cache space. Consider wrapping the share call in a try/finally that removes the file via Filesystem.deleteFile.

♻️ Proposed cleanup with try/finally
   const { uri } = await Filesystem.writeFile({
     path: safeFilename,
     data: response.data as string,
     directory: Directory.Cache,
   });

   try {
     await Share.share({ url: uri, title: safeFilename });
   } catch (error) {
     if (error instanceof Error && /cancel/i.test(error.message)) {
       return;
     }
     throw error;
-  }
+  } finally {
+    await Filesystem.deleteFile({ path: safeFilename, directory: Directory.Cache }).catch(() => {});
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { uri } = await Filesystem.writeFile({
path: safeFilename,
data: response.data as string,
directory: Directory.Cache,
});
try {
await Share.share({ url: uri, title: safeFilename });
} catch (error) {
// Dismissing the share sheet rejects too: the file was downloaded, the
// user just changed their mind — not a failure to report.
if (error instanceof Error && /cancel/i.test(error.message)) {
return;
}
throw error;
}
const { uri } = await Filesystem.writeFile({
path: safeFilename,
data: response.data as string,
directory: Directory.Cache,
});
try {
await Share.share({ url: uri, title: safeFilename });
} catch (error) {
// Dismissing the share sheet rejects too: the file was downloaded, the
// user just changed their mind — not a failure to report.
if (error instanceof Error && /cancel/i.test(error.message)) {
return;
}
throw error;
} finally {
await Filesystem.deleteFile({ path: safeFilename, directory: Directory.Cache }).catch(() => {});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/features/native/download.ts` around lines 52 - 67, The
download flow in the native share path leaves the cached file behind after
Filesystem.writeFile and Share.share. Update the logic in the download/share
function to ensure the temporary file is always cleaned up by removing it with
Filesystem.deleteFile after sharing completes or fails, while still preserving
the existing cancellation handling in the Share.share error path. Use the
existing safeFilename, uri, and Share.share flow to place the cleanup in a
try/finally-style structure so the cache entry does not accumulate.

The upcoming Capacitor mobile shell replays the Django session cookie
through its native HTTP layer but not the `csrftoken` cookie, so
cookie-based CSRF would break every mutation on mobile. Enabling
CSRF_USE_SESSIONS moves the secret server-side and removes the need for
a JS-readable cookie: the token is now delivered on the authenticated
/users/me/ response, cached in memory by the SPA and echoed as
X-CSRFToken. On web this is equivalent or safer — the secret is no
longer readable by scripts nor overridable via cross-subdomain cookie
tossing.
Capacitor apps must run the OIDC flow in the system browser (the IdP
cookie has to live there to provide cross-app SSO), but the browser's
cookies never reach the app's native HTTP layer, so the Django session
created by the callback would be stranded. The callback now redirects
to an allowlisted app deep link with a one-time token that the app
exchanges for its session cookie and CSRF token. The token is bound to
the initiating app instance with a PKCE S256 verifier, single-use,
short-lived (MOBILE_AUTH_TOKEN_TTL) and the anonymous exchange endpoint
is throttled per IP to cap brute-force guessing. An empty
MOBILE_AUTH_CALLBACK_SCHEMES (the default) keeps the whole handoff
disabled.
The backend sends acr_values=eidas1 on every authorization request
(OIDC_AUTH_REQUEST_EXTRA_PARAMS) but the dev realm had an empty
acr.loa.map, so Keycloak treated the value as an unknown essential acr
claim. Web logins survived it, but the mobile system-browser flow
(ASWebAuthenticationSession / Custom Tabs) failed the login round-trip,
breaking cross-app SSO in dev. Mapping eidas1 to LoA 1 mirrors what the
production IdP declares.
Ship the existing SPA as native iOS/Android apps without forking the
codebase: Capacitor wraps the web build, and every mobile-specific
behavior is gated behind isNativePlatform() so the web app is
untouched. The native shells route fetch/cookies through the native
HTTP layer (CapacitorHttp) — the WebView cookie jar is unreliable for
cross-origin sessions — which is why login runs in the system browser
(cross-app SSO via the shared IdP cookie) and finishes through the
backend session handoff, with the deep-link scheme pinned by
sso-invariants tests. Downloads/share go through the Filesystem/Share
plugins since WebView navigation would lose the session.
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.
@jbpenrath

Copy link
Copy Markdown
Contributor Author

@coderabbitai help

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

CodeRabbit commands

These commands are invoked using PR/Issue comments.

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai rate limit to show your current review rate limit status.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai evaluate custom pre-merge check --instructions <custom-checks-instructions> --name <custom-checks-title> [--mode <error|warning>] to validate the custom pre-merge checks instructions. Defaults to error when --mode is omitted.
  • @coderabbitai ignore pre-merge checks to override pre-merge checks and get an approval on PR.
  • @coderabbitai run pre-merge checks to run pre-merge checks on the pull request.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai emit path instructions to emit generated path-specific review instructions for this repository.
  • @coderabbitai resolve merge conflict to automatically resolve merge conflicts.
  • @coderabbitai autofix to automatically fix issues identified in unresolved review comments.
  • @coderabbitai resolve to resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai generate configuration to create a PR that adds the current resolved configuration as .coderabbit.yaml (or show it if already present).
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit configuration file (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, support, documentation and community

  • Visit our status page to check the current availability of CodeRabbit.
  • Create a ticket on our support page for assistance with any issues or questions.
  • Visit our documentation site for detailed information on how to use CodeRabbit.
  • Join our Discord community to connect with other users and get help from the community.
  • Follow us on X/Twitter for updates and announcements.

@jbpenrath

Copy link
Copy Markdown
Contributor Author

@coderabbitai pause

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

♻️ Duplicate comments (2)
src/frontend/src/bootstrap.tsx (1)

88-91: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

notifyOtaAppReady() still fires before the React tree commits.

This was flagged in a previous review: createRoot(...).render() in React 19 is not guaranteed to commit synchronously, so notifyOtaAppReady() can bless an OTA bundle before the app is actually mounted. If the initial render fails after notifyAppReady has been called, the CapacitorUpdater plugin won't auto-revert, potentially causing a boot loop. The return added in the catch path correctly prevents signaling readiness on bootstrap failure, but doesn't cover async render failures.

Move notifyOtaAppReady() into the mounted root shell's useEffect so "ready" aligns with the first committed render.

🔧 Suggested fix
 // In bootstrap.tsx — remove the fire-and-forget call:
-  void notifyOtaAppReady();
 };

 // In src/frontend/src/routes/__root.tsx — call from useEffect:
+import { notifyOtaAppReady } from "`@/features/native/ota`";
+
+export const Root = () => {
+  useEffect(() => {
+    void notifyOtaAppReady();
+  }, []);
+  // ...
+};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/bootstrap.tsx` around lines 88 - 91, Move the
notifyOtaAppReady call from bootstrap.tsx’s post-render path into the mounted
root shell component’s useEffect, ensuring it runs only after the initial React
tree commits. Keep the existing bootstrap catch return so synchronous setup
failures do not signal readiness, and remove the unconditional call after
createRoot(...).render().
src/frontend/src/features/native/auth.ts (1)

60-61: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate csrfToken before storing it.

This issue was previously flagged and remains unfixed. If the exchange response omits csrf_token, the destructured value is undefined, which setNativeCsrfToken stores as the string "undefined" in localStorage. All subsequent CSRF-protected requests fail with 403, leaving the user stuck after a seemingly successful login.

🛡️ Proposed fix
     const { csrf_token: csrfToken } = (await response.json()) as ExchangeResponse;
+    if (!csrfToken) {
+      throw new Error("Mobile session exchange did not return a CSRF token.");
+    }
     setNativeCsrfToken(csrfToken);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/features/native/auth.ts` around lines 60 - 61, Validate
csrfToken in the authentication exchange flow before calling setNativeCsrfToken;
if it is missing or invalid, handle the exchange as an error rather than storing
it and continuing. Update the surrounding response handling to ensure subsequent
requests only proceed after a valid CSRF token is present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/mobile.md`:
- Line 31: Update the fenced code blocks in docs/mobile.md, including the blocks
at the referenced locations, to include language identifiers; use text for
diagrams or output and the appropriate language for executable examples.

In `@src/frontend/android/app/src/main/res/values/strings.xml`:
- Line 6: Remove the unused custom_url_scheme string resource from strings.xml,
since the callback flow consistently uses stmessages in auth.ts and
AndroidManifest.xml.

In `@src/frontend/android/gradlew.bat`:
- Around line 1-94: Add a repository-level .gitattributes file with a *.bat rule
specifying eol=crlf, ensuring Windows batch files such as gradlew.bat are
checked out with CRLF line endings; normalize the existing batch file
accordingly.

In `@src/frontend/ios/App/App/AppDelegate.swift`:
- Around line 27-29: Handle errors from url.setResourceValues(values) explicitly
instead of using try?, and log or report the failure with the affected URL and
error details so backup-exclusion failures are observable and actionable.

In `@src/frontend/scripts/create-ota-bucket.mjs`:
- Around line 16-22: In the HeadBucket handling around client.send and
HeadBucketCommand, capture the thrown error and only proceed to
CreateBucketCommand when its status indicates 404; re-throw all other errors so
permission, network, and unexpected failures are preserved.

In `@src/frontend/scripts/publish-ota.mjs`:
- Around line 80-86: Add a 300,000 ms timeout to the options object passed by
the capgo function’s execFileSync call, ensuring stalled Capgo CLI operations
terminate instead of blocking the publish script indefinitely.

In `@src/frontend/src/features/native/ota.test.ts`:
- Around line 126-262: Add a test in the checkAndApplyOtaUpdate suite that stubs
MOBILE_DEV_SERVER_URL to a non-empty value, invokes checkAndApplyOtaUpdate with
MANIFEST_URL, and asserts ctx.http.get is not called. Ensure the test isolates
and restores the environment stub.

---

Duplicate comments:
In `@src/frontend/src/bootstrap.tsx`:
- Around line 88-91: Move the notifyOtaAppReady call from bootstrap.tsx’s
post-render path into the mounted root shell component’s useEffect, ensuring it
runs only after the initial React tree commits. Keep the existing bootstrap
catch return so synchronous setup failures do not signal readiness, and remove
the unconditional call after createRoot(...).render().

In `@src/frontend/src/features/native/auth.ts`:
- Around line 60-61: Validate csrfToken in the authentication exchange flow
before calling setNativeCsrfToken; if it is missing or invalid, handle the
exchange as an error rather than storing it and continuing. Update the
surrounding response handling to ensure subsequent requests only proceed after a
valid CSRF token is present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 78bd07e3-38fe-4055-9102-1001de9ae871

📥 Commits

Reviewing files that changed from the base of the PR and between 02c35ca and 0ccf9bc.

⛔ Files ignored due to path filters (119)
  • src/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-ldpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-hdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-ldpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-mdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-xhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-night/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-ldpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-hdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-ldpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-mdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-xhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/drawable/splash.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-ldpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png is excluded by !**/*.png
  • src/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png is excluded by !**/*.png
  • src/frontend/android/gradle/wrapper/gradle-wrapper.jar is excluded by !**/*.jar
  • src/frontend/assets/icon-dark.png is excluded by !**/*.png
  • src/frontend/assets/icon-foreground.png is excluded by !**/*.png
  • src/frontend/assets/icon-only.png is excluded by !**/*.png
  • src/frontend/assets/splash-dark.png is excluded by !**/*.png
  • src/frontend/assets/splash.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany-dark.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@1x~universal~anyany.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany-dark.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@2x~universal~anyany.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany-dark.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Default@3x~universal~anyany.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png is excluded by !**/*.png
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png is excluded by !**/*.png
  • src/frontend/package-lock.json is excluded by !**/package-lock.json
  • src/frontend/public/images/anct/app-icon-2x.png is excluded by !**/*.png
  • src/frontend/public/images/anct/app-icon.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-144-144.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-192-192.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-48-48.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-512-512.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-72-72.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/android-launchericon-96-96.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-144x144.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-192x192.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-48x48.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-512x512.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-72x72.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/android/launchericon-96x96.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/100.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/1024.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/114.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/120.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/128.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/144.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/152.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/16.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/167.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/180.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/192.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/20.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/256.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/29.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/32.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/40.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/50.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/512.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/57.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/58.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/60.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/64.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/72.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/76.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/80.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/ios/87.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-120.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-128.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-144.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-152.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-180.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-192.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-384.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-512.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-72.png is excluded by !**/*.png
  • src/frontend/public/images/pwa/icons/pwa/icon-96.png is excluded by !**/*.png
  • src/frontend/src/features/api/gen/models/config_retrieve200.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/user_with_abilities.ts is excluded by !**/gen/**
📒 Files selected for processing (113)
  • Makefile
  • compose.yaml
  • docs/env.md
  • docs/mobile.md
  • env.d/development/backend.defaults
  • env.d/development/frontend.defaults
  • src/backend/core/api/openapi.json
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/config.py
  • src/backend/core/api/viewsets/mobile_auth.py
  • src/backend/core/authentication/views.py
  • src/backend/core/management/commands/create_bucket.py
  • src/backend/core/tests/api/test_attachments.py
  • src/backend/core/tests/api/test_config.py
  • src/backend/core/tests/api/test_users.py
  • src/backend/core/tests/authentication/test_mobile_auth.py
  • src/backend/core/urls.py
  • src/backend/messages/settings.py
  • src/e2e/src/__tests__/thread-event.spec.ts
  • src/frontend/Dockerfile
  • src/frontend/android/.gitignore
  • src/frontend/android/app/.gitignore
  • src/frontend/android/app/build.gradle
  • src/frontend/android/app/capacitor.build.gradle
  • src/frontend/android/app/proguard-rules.pro
  • src/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
  • src/frontend/android/app/src/main/AndroidManifest.xml
  • src/frontend/android/app/src/main/java/local/suitenumerique/messages/MainActivity.java
  • src/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
  • src/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml
  • src/frontend/android/app/src/main/res/layout/activity_main.xml
  • src/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  • src/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  • src/frontend/android/app/src/main/res/values-night/colors.xml
  • src/frontend/android/app/src/main/res/values/colors.xml
  • src/frontend/android/app/src/main/res/values/strings.xml
  • src/frontend/android/app/src/main/res/values/styles.xml
  • src/frontend/android/app/src/main/res/xml/file_paths.xml
  • src/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java
  • src/frontend/android/build.gradle
  • src/frontend/android/capacitor.settings.gradle
  • src/frontend/android/gradle.properties
  • src/frontend/android/gradle/wrapper/gradle-wrapper.properties
  • src/frontend/android/gradlew
  • src/frontend/android/gradlew.bat
  • src/frontend/android/settings.gradle
  • src/frontend/android/variables.gradle
  • src/frontend/capacitor.config.ts
  • src/frontend/eslint.config.mjs
  • src/frontend/ios/.gitignore
  • src/frontend/ios/App/App.xcodeproj/project.pbxproj
  • src/frontend/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  • src/frontend/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  • src/frontend/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme
  • src/frontend/ios/App/App/AppDelegate.swift
  • src/frontend/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
  • src/frontend/ios/App/App/Assets.xcassets/Contents.json
  • src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
  • src/frontend/ios/App/App/Base.lproj/LaunchScreen.storyboard
  • src/frontend/ios/App/App/Base.lproj/Main.storyboard
  • src/frontend/ios/App/App/Info.plist
  • src/frontend/ios/App/App/MainViewController.swift
  • src/frontend/ios/App/App/WebAuthSessionPlugin.swift
  • src/frontend/ios/App/CapApp-SPM/.gitignore
  • src/frontend/ios/App/CapApp-SPM/Package.swift
  • src/frontend/ios/App/CapApp-SPM/README.md
  • src/frontend/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift
  • src/frontend/ios/debug.xcconfig
  • src/frontend/package.json
  • src/frontend/public/assets/icons/icon-128.webp
  • src/frontend/public/assets/icons/icon-192.webp
  • src/frontend/public/assets/icons/icon-256.webp
  • src/frontend/public/assets/icons/icon-48.webp
  • src/frontend/public/assets/icons/icon-512.webp
  • src/frontend/public/assets/icons/icon-72.webp
  • src/frontend/public/assets/icons/icon-96.webp
  • src/frontend/public/browserconfig.xml
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/public/manifest.json
  • src/frontend/scripts/create-ota-bucket.mjs
  • src/frontend/scripts/generate-ota-keys.mjs
  • src/frontend/scripts/ota-lib.mjs
  • src/frontend/scripts/publish-ota.mjs
  • src/frontend/src/bootstrap.tsx
  • src/frontend/src/features/api/csrf.test.ts
  • src/frontend/src/features/api/csrf.ts
  • src/frontend/src/features/api/utils.ts
  • src/frontend/src/features/auth/index.tsx
  • src/frontend/src/features/config/resolve.test.ts
  • src/frontend/src/features/config/resolve.ts
  • src/frontend/src/features/layouts/components/main/header/_index.scss
  • src/frontend/src/features/layouts/components/thread-view/components/attachment-preview-modal/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-attachment-list/attachment-item.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-actions.tsx
  • src/frontend/src/features/native/auth-session.ts
  • src/frontend/src/features/native/auth.ts
  • src/frontend/src/features/native/csrf.test.ts
  • src/frontend/src/features/native/csrf.ts
  • src/frontend/src/features/native/download.test.ts
  • src/frontend/src/features/native/download.ts
  • src/frontend/src/features/native/ota.test.ts
  • src/frontend/src/features/native/ota.ts
  • src/frontend/src/features/native/pkce.test.ts
  • src/frontend/src/features/native/pkce.ts
  • src/frontend/src/features/native/platform.ts
  • src/frontend/src/features/native/sso-invariants.test.ts
  • src/frontend/src/features/native/use-native-download.tsx
  • src/frontend/src/styles/globals.scss
  • src/frontend/src/styles/views/domain/_index.scss
  • src/frontend/src/vite-env.d.ts
  • src/frontend/vite.config.ts
  • src/keycloak/realm.json

Comment thread docs/mobile.md

## Architecture at a glance

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add language identifiers to fenced code blocks.

These fences trigger markdownlint MD040. Use text for diagrams/output blocks, or the appropriate language for executable snippets.

Also applies to: 82-82, 261-261

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 31-31: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/mobile.md` at line 31, Update the fenced code blocks in docs/mobile.md,
including the blocks at the referenced locations, to include language
identifiers; use text for diagrams or output and the appropriate language for
executable examples.

Source: Linters/SAST tools

<string name="app_name">Messages</string>
<string name="title_activity_main">Messages</string>
<string name="package_name">local.suitenumerique.messages</string>
<string name="custom_url_scheme">local.suitenumerique.messages</string>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

🧩 Analysis chain

🏁 Script executed:

rg -n 'custom_url_scheme|stmessages' src/frontend/android src/frontend/src

Repository: suitenumerique/messages

Length of output: 629


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate all references to the resource and callback scheme.
rg -n 'custom_url_scheme|stmessages' src/frontend/android src/frontend/src

# Show the relevant Android resource file and the auth/manifest consumers with line numbers.
sed -n '1,80p' src/frontend/android/app/src/main/res/values/strings.xml
printf '\n--- AndroidManifest.xml ---\n'
sed -n '1,120p' src/frontend/android/app/src/main/AndroidManifest.xml
printf '\n--- auth.ts ---\n'
sed -n '1,80p' src/frontend/src/features/native/auth.ts

Repository: suitenumerique/messages

Length of output: 5946


Remove the unused custom_url_scheme resource. The callback flow already uses stmessages in both auth.ts and AndroidManifest.xml, so this string is stale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/android/app/src/main/res/values/strings.xml` at line 6, Remove
the unused custom_url_scheme string resource from strings.xml, since the
callback flow consistently uses stmessages in auth.ts and AndroidManifest.xml.

Comment on lines +1 to +94
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem

@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################

@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal

set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%

@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"

@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome

set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute

echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2

goto fail

:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe

if exist "%JAVA_EXE%" goto execute

echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2

goto fail

:execute
@rem Setup the command line

set CLASSPATH=


@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*

:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd

:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%

:mainEnd
if "%OS%"=="Windows_NT" endlocal

:omega

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for .gitattributes files that might enforce CRLF for .bat files
fd -H .gitattributes --type f | xargs -I {} sh -c 'echo "=== {} ===" && cat {}'

Repository: suitenumerique/messages

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== .gitattributes files =="
fd -H -a '^\.gitattributes$' . || true

echo
echo "== line-ending / attributes policy mentions =="
rg -n --hidden --glob '!.git' -e 'eol=crlf|\.bat|gitattributes|line endings|autocrlf' .

echo
echo "== inspect relevant wrapper file metadata =="
git ls-files --eol -- src/frontend/android/gradlew.bat .gitattributes 2>/dev/null || true

Repository: suitenumerique/messages

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tracked .gitattributes files =="
git ls-files -z -- '*.gitattributes' | tr '\0' '\n' || true

echo
echo "== tracked EOL metadata for src/frontend/android/gradlew.bat =="
git ls-files --eol -- src/frontend/android/gradlew.bat || true

echo
echo "== repo line-ending policy files =="
git ls-files | rg '(^|/)(\.editorconfig|\.gitattributes|core\.autocrlf|\.gitignore)$' || true

echo
echo "== any explicit CRLF rules =="
rg -n --hidden --glob '!.git' 'eol=crlf|text eol=crlf|*.bat' .editorconfig .gitattributes . || true

Repository: suitenumerique/messages

Length of output: 766


Add a .gitattributes rule for *.bat
src/frontend/android/gradlew.bat is tracked with LF endings, and this repo has no .gitattributes to force CRLF on Windows. Batch files should be checked out with eol=crlf to avoid parser failures.

🧰 Tools
🪛 Blinter (1.0.113)

[error] 1-1: Unix line endings detected. Explanation: Batch file uses Unix line endings (LF-only) which can cause GOTO/CALL label parsing failures and script malfunction due to Windows batch parser 512-byte boundary bugs. Recommendation: Convert file to Windows line endings (CRLF). Use tools like dos2unix, notepad++, or configure git with 'git config core.autocrlf true'. Context: File uses Unix line endings (LF-only) - 94 LF sequences found

(E018)


[error] 88-88: Complex quote escaping error. Explanation: Complex quote patterns may not be handled correctly by the command interpreter. Recommendation: Use triple-quote pattern """text""" for quotes within quoted strings, or escape properly with variables. Context: Complex quote escaping detected

(E028)


[warning] 19-19: Potentially undefined variable reference. Explanation: Script references variables that were never set in this script. This may be intentional if using environment variables, but could cause runtime errors if the variable is not set by parent process or system. Recommendation: If this is an environment variable, this warning can be ignored. Otherwise, define the variable using SET before referencing it, or add IF DEFINED checks to handle undefined cases. Context: Variable 'DEBUG' is used but never defined. Variable 'JAVA_OPTS' is used but never defined. Variable 'GRADLE_OPTS' is used but never defined. Variable 'GRADLE_EXIT_CONSOLE' is used but never defined

(E006)


[warning] 92-92: Missing exit code. Explanation: Script doesn't set appropriate exit codes to indicate success or failure. Recommendation: Add EXIT /b 0 for success or EXIT /b 1 for errors at script end. Context: Script can reach end of file without explicit EXIT statement

(W001)


[warning] 49-49: Unicode handling issue. Explanation: Command contains non-ASCII characters or complex operations that may not handle Unicode properly. Note: Only flags lines with actual Unicode content or unsafe operations, not all echo/type/find commands. Recommendation: Consider using commands with better Unicode support, or ensure proper code page (chcp 65001 for UTF-8). Context: Command 'echo' may have Unicode handling issues

(W011)


[warning] 29-29: Errorlevel handling difference between .bat/.cmd. Explanation: Commands like APPEND, DPATH, FTYPE, SET, PATH, ASSOC handle errorlevel differently in .bat vs .cmd files. Recommendation: Use .cmd extension for consistent errorlevel behavior with these commands. Context: Command 'set' handles errorlevel differently in .bat vs .cmd files

(W028)


[error] 29-29: Unsafe SET command usage. Explanation: SET commands without proper validation or quoting can cause security issues. Recommendation: Always quote SET values and validate input: SET "var=safe value". Context: SET command value should be quoted for safety

(SEC002)


[error] 45-45: Command injection via variable substitution. Explanation: Variables containing user input used in commands may allow code injection. Recommendation: Validate and sanitize variables before use in command execution. Context: Variable used with shell operators may allow injection

(SEC013)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/android/gradlew.bat` around lines 1 - 94, Add a repository-level
.gitattributes file with a *.bat rule specifying eol=crlf, ensuring Windows
batch files such as gradlew.bat are checked out with CRLF line endings;
normalize the existing batch file accordingly.

Source: Linters/SAST tools

Comment on lines +27 to +29
var values = URLResourceValues()
values.isExcludedFromBackup = true
try? url.setResourceValues(values)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not silently discard backup-exclusion failures.

If setResourceValues fails, session cookies or CSRF data may remain backup-eligible while the app reports no failure, defeating this security control. Handle the error explicitly and log/report it so the failure is observable and actionable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/ios/App/App/AppDelegate.swift` around lines 27 - 29, Handle
errors from url.setResourceValues(values) explicitly instead of using try?, and
log or report the failure with the affected URL and error details so
backup-exclusion failures are observable and actionable.

Comment on lines +16 to +22
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.`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== src/frontend/scripts/create-ota-bucket.mjs ==\n'
wc -l src/frontend/scripts/create-ota-bucket.mjs
cat -n src/frontend/scripts/create-ota-bucket.mjs | sed -n '1,120p'

printf '\n== ota-lib.mjs references ==\n'
rg -n "readManifest|HeadBucketCommand|CreateBucketCommand|httpStatusCode|404" src/frontend -S

Repository: suitenumerique/messages

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== src/frontend/scripts/ota-lib.mjs (relevant lines) ==\n'
sed -n '96,120p' src/frontend/scripts/ota-lib.mjs | cat -n

printf '\n== src/frontend/scripts/create-ota-bucket.mjs (full file) ==\n'
sed -n '1,80p' src/frontend/scripts/create-ota-bucket.mjs | cat -n

Repository: suitenumerique/messages

Length of output: 2691


🌐 Web query:

AWS S3 HeadBucket 403 404 missing bucket error codes documentation

💡 Result:

The AWS S3 HeadBucket operation is used to determine if a bucket exists and if you have the necessary permissions to access it [1][2][3]. When this operation is called, it returns a 200 OK status code if the bucket exists and you have access [1][2][3]. If the bucket does not exist or you lack the required permissions, the operation returns a generic 400 Bad Request, 403 Forbidden, or 404 Not Found HTTP status code [4][1][2][3]. Crucially, for security reasons, these responses do not include a message body [1][2][3]. Consequently, you cannot programmatically distinguish between a missing bucket and a permission error based on the response body, as the API obscures the underlying exception beyond these generic HTTP codes [1][2][3]. Specific notes regarding these codes: - 403 Forbidden: This can occur if you do not have permission to access the bucket, or if you specify an ExpectedBucketOwner that does not match the actual owner of the bucket [1][2][3]. - 404 Not Found: This is returned if the bucket does not exist [4][1][2]. Because the response is intentionally generic, your application logic should treat these codes as a combined indicator that the requested bucket is either unavailable or inaccessible [1][3].

Citations:


Narrow the HeadBucket catch to 404-only.
The bare catch turns any HeadBucket failure into a CreateBucket attempt, which hides permission and network errors behind a misleading second failure. Re-throw everything except 404 so only a missing bucket falls through to creation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/scripts/create-ota-bucket.mjs` around lines 16 - 22, In the
HeadBucket handling around client.send and HeadBucketCommand, capture the thrown
error and only proceed to CreateBucketCommand when its status indicates 404;
re-throw all other errors so permission, network, and unexpected failures are
preserved.

Comment on lines +80 to +86
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);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant file with line numbers
cat -n src/frontend/scripts/publish-ota.mjs | sed -n '1,180p'

# Find any other execFileSync usage or timeout handling in the same area
rg -n "execFileSync|timeout:" src/frontend/scripts/publish-ota.mjs src/frontend/scripts -S

Repository: suitenumerique/messages

Length of output: 8317


Add a timeout to the Capgo CLI call execFileSync here has no limit, so a stuck Capgo run can block the publish script—and CI—until the job times out. timeout: 300_000 is a reasonable ceiling for zip/encrypt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/scripts/publish-ota.mjs` around lines 80 - 86, Add a 300,000 ms
timeout to the options object passed by the capgo function’s execFileSync call,
ensuring stalled Capgo CLI operations terminate instead of blocking the publish
script indefinitely.

Comment on lines +126 to +262
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);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a test for the hot reload skip guard.

The hot reload skip (import.meta.env.DEV && import.meta.env.MOBILE_DEV_SERVER_URL) at ota.ts:85-87 is the only guard without test coverage. If it breaks, OTA would be applied during a Vite dev session, killing the hot reload. import.meta.env.DEV is already true in Vitest, so a test只需 stubs MOBILE_DEV_SERVER_URL to a non-empty value and asserts http.get is not called.

🧪 Suggested test
   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("skips OTA during a hot reload session", async () => {
+    const ctx = await loadOta();
+    vi.stubEnv("MOBILE_DEV_SERVER_URL", "http://localhost:8900");
+    await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
+    expect(ctx.http.get).not.toHaveBeenCalled();
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
});
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("skips OTA during a hot reload session", async () => {
const ctx = await loadOta();
vi.stubEnv("MOBILE_DEV_SERVER_URL", "http://localhost:8900");
await ctx.ota.checkAndApplyOtaUpdate(MANIFEST_URL);
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);
});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/features/native/ota.test.ts` around lines 126 - 262, Add a
test in the checkAndApplyOtaUpdate suite that stubs MOBILE_DEV_SERVER_URL to a
non-empty value, invokes checkAndApplyOtaUpdate with MANIFEST_URL, and asserts
ctx.http.get is not called. Ensure the test isolates and restores the
environment stub.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews paused.

Notably, we try to reduce disk usage by standardizind on common
base Docker images. We also improve node_modules by reducing
duplicate dependencies and install speed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants