Skip to main content

One export WebView — iOS app handoff

This describes what the iOS app must implement to give its users the enode portal's data-export flow, even though those users can't access the portal normally (their license doesn't grant portal access). The portal side is already built — the route /export (see apps/portal/src/app/export/). This document is the contract the app implements against. It is the sibling of migration-ios-handoff.md — same mechanism, its own contract names.

Goal

Users of the iOS app need the portal's data export (their training data as CSV, delivered by email) but are blocked from the portal: the backend rejects a license-mismatched account at portal login with HTTP 314.

Solution: open the portal's dedicated, unlinked /export route inside an in-app WebView and inject the user's existing bearer token so no login happens — which also sidesteps the 314 gate, because the user arrives already authenticated (314 is a login-time check).

Unlike the coach-facing portal export there is no athlete selection: the flow always exports the signed-in user themselves.

The entire export UI lives in the web route. The app builds only the entry point, the WebView, and the token injection — no export screens.

What the app must do

  1. An entry point somewhere appropriate (settings / profile — the app team's call) that opens the export WebView modally.

  2. A WKWebView — it MUST be WKWebView, not SFSafariViewController (SFSafariViewController can't run injected JavaScript). Load:

    • https://<PORTAL_HOST>/export/trailing slash required (the portal is a static export with trailingSlash: true).
    • Get <PORTAL_HOST> per environment (dev / staging / prod) from the portal team.
  3. Native token injection via a WKUserScript at .atDocumentStart (runs before the web app's own JavaScript), setting a known global:

    // `token` = the user's current bearer JWT — the exact value the app already
    // sends as `Authorization: Bearer <token>` on its own API calls.
    let js = "window.__ENODE_EXPORT_TOKEN__ = \"\(token)\";"
    let script = WKUserScript(source: js,
    injectionTime: .atDocumentStart,
    forMainFrameOnly: true)

    let controller = WKUserContentController()
    controller.addUserScript(script)

    let config = WKWebViewConfiguration()
    config.userContentController = controller
    // Ephemeral store: the injected token / the portal's localStorage do not
    // persist after the WebView closes.
    config.websiteDataStore = .nonPersistent()

    let webView = WKWebView(frame: .zero, configuration: config)
    webView.load(URLRequest(url: exportURL))

    (The token is a base64url JWT — no quotes/newlines — but still build the JS string carefully.)

The contract (must match the portal exactly)

ItemValue
Injected globalwindow.__ENODE_EXPORT_TOKEN__
Valuethe user's current bearer JWT
Injection timingWKUserScript at .atDocumentStart (before the web app's JS)
URLhttps://<PORTAL_HOST>/export/

The portal reads the global on mount, stores it, deletes the global, then runs the flow. If the global is absent (and no token is otherwise stored) the portal shows an "open the export from the app" message — so injection MUST happen before first paint, which .atDocumentStart guarantees.

An app that already implements the migration WebView can reuse its injection helper verbatim — only the global name and the URL differ.

Optional — auto-dismiss on completion

If you want the app to close the WebView automatically (instead of the user closing it manually), add a WKScriptMessageHandler named enodeExport. The portal posts this when the user taps Done on the success panel:

window.webkit.messageHandlers.enodeExport.postMessage({ event: "close" });
controller.add(self, name: "enodeExport") // on the same WKUserContentController

func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
if message.name == "enodeExport",
let body = message.body as? [String: Any],
body["event"] as? String == "close" {
// dismiss the WebView
}
}

For v1 this is optional — without the handler the user just closes the WebView.

What the portal side handles (so you know the boundary)

  • Reads the injected token and runs the full export flow: date-window quick presets seeded from the user's own history, timeframe / exercises / metrics selection, then submits the export job (POST /file_export) for the token's own account only.
  • Delivery is by email: the backend runs the export asynchronously and mails a download link to the account's email address. The WebView shows an "Export started — we'll email you the file" panel; nothing is downloaded inside the WebView. Worth mentioning in the app's entry-point copy.
  • Session expiry: a 401 mid-flow shows a "session expired — reopen from the app" message; it does NOT redirect to the portal login (which these users can't pass). The app just needs to let the user reopen the WebView with a fresh token.
  • You implement no export UI or API calls — only the WebView + token injection.

Security notes

  • The token is injected as a JS global — it never appears in a URL, query string, server access log, or Referer header.
  • Use a non-persistent WKWebsiteDataStore so the token and the portal's localStorage are cleared when the WebView closes.
  • HTTPS only; ensure ATS allows <PORTAL_HOST>.
  • No new secret is introduced — it's the user's existing session token.

Open coordination items (NOT iOS work — tracked by the portal/backend team)

  1. The exact /export URL per environment (dev / staging / prod).
  2. Backend confirmation that a One account's token succeeds against the flow's endpoints under the portal api-keyPOST /file_export, GET /history/scope, GET /exercises and the reference-data reads — i.e. the flavor/314 gate is login-time only and the backend does not require the bearer token's flavor to match the api-key header's flavor per request. Same nuance as the migration handoff's item 2.