# El Captain — native shell

A thin native wrapper around the member portal at `{baseUrl}/app`, built once
per client.

**Status: not shipped this cycle.** The code is complete and the constraints
below are the reason it is not on a store yet — see E3 in
`docs/specs/mobile-portal/02-critique-addendum.md`. The portal is a PWA and is
installable today; this exists so shipping natively is a decision rather than a
project.

## Why a wrapper at all

Everything a member does is in the portal, and building it twice was the
mistake that produced the API this programme deleted. What a wrapper adds is
the handful of things a browser genuinely cannot do: a home-screen presence
Apple will list, native push without a Safari permission dance, a camera that
opens instantly, biometric re-entry, and a file picker that works.

## What must be native on day one

A pure WebView wrapper is Guideline 4.2 ("Minimum Functionality") and will be
rejected. These are not enhancements to add later — they are the reason the
submission is accepted:

- **FCM natively**, not through the page.
- **Camera / QR natively** (`mobile_scanner`), decoded string passed in over a
  channel.
- **Biometric re-entry** gating a *native* action.
- **Native file picker** for the transfer-proof upload.
- **Share sheet** for receipts.

Also confirm with the reviewer that fees and kit are real-world goods
(3.1.3(e) / 3.1.5), or Apple will demand IAP at 30% on academy subscriptions.

## Security model

The shell holds **one** long-lived Sanctum token, in the Keychain or
EncryptedSharedPreferences, with the single ability `portal:session`. On launch
it calls `/app/session-exchange`, which turns that token into an ordinary web
session in the WebView's own cookie jar. Everything after that is a normal
session request with CSRF intact.

Three rules that are load-bearing:

1. **The token never reaches JavaScript.** Not `localStorage`, not a JS
   variable, not a query string. It lives in native storage and is sent by
   native code.
2. **`/app/*` is never exempted from CSRF.** "The native app can't get a token"
   is the shortcut that turns this from safe into trivially exploitable.
3. **Every bridge is an exported native capability.** The host allowlist is
   checked in `NavigationDelegate` against the origin read from
   `controller.currentUrl()` — natively, never from the page. Biometrics gate a
   native action and never return a boolean the page is trusted to honour; the
   file picker returns a handle, not a path.

## Why `flutter_inappwebview` and not `webview_flutter`

`<input type="file">` is inert in a bare Android WebView without
`onShowFileChooser`. That single gap would break the transfer-proof upload,
which is the portal's whole money path.

## Deep links

Verified App Links and Universal Links, which means
`/.well-known/assetlinks.json` and `/.well-known/apple-app-site-association`
must be served as `application/json` with no redirect. The nginx config already
has exact-match locations for both — without them the static-asset regex would
answer 404 and the platform would silently refuse to verify the domain.

## Per client

One `instance.dart`, one Firebase project, one icon set. Nothing else differs.

```
flutter build appbundle --dart-define=BASE_URL=https://<client>.example.com
```
