Migration WebView — iOS app handoff
This describes what the iOS app must implement to give its users the enode
portal's data-migration 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 /migrate (see apps/portal/src/app/migrate/). This document
is the contract the app implements against.
Goal
Users of the iOS app need the portal's migration flow (migrate data from another system into their enode account) 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 /migrate 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).
The entire migration UI lives in the web route. The app builds only the entry point, the WebView, and the token injection — no migration screens.
What the app must do
-
An entry point somewhere appropriate (settings / onboarding — the app team's call) that opens the migration WebView modally.
-
A
WKWebView— it MUST beWKWebView, notSFSafariViewController(SFSafariViewControllercan't run injected JavaScript). Load:https://<PORTAL_HOST>/migrate/— trailing slash required (the portal is a static export withtrailingSlash: true).- Get
<PORTAL_HOST>per environment (dev / staging / prod) from the portal team.
-
Native token injection via a
WKUserScriptat.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_MIGRATION_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: migrateURL))(The token is a base64url JWT — no quotes/newlines — but still build the JS string carefully.)
The contract (must match the portal exactly)
| Item | Value |
|---|---|
| Injected global | window.__ENODE_MIGRATION_TOKEN__ |
| Value | the user's current bearer JWT |
| Injection timing | WKUserScript at .atDocumentStart (before the web app's JS) |
| URL | https://<PORTAL_HOST>/migrate/ |
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 migration from the app" message — so injection MUST happen before
first paint, which .atDocumentStart guarantees.
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 enodeMigration.
The portal posts this when the flow ends (or is cancelled):
window.webkit.messageHandlers.enodeMigration.postMessage({ event: "close" });
controller.add(self, name: "enodeMigration") // on the same WKUserContentController
func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
if message.name == "enodeMigration",
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 migration wizard: connect the source account (its own email + OTP, inside the wizard), select people / groups / timeframe, map exercises, confirm, and start the import.
- 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). So a token that expires mid-migration fails gracefully — the app just needs to let the user reopen the WebView with a fresh token.
- You implement no migration UI, OTP handling, 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
Refererheader. - Use a non-persistent
WKWebsiteDataStoreso 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)
- The exact
/migrateURL per environment (dev / staging / prod). - Backend confirmation that a non-portal account's token succeeds against
all migration endpoints — i.e. the flavor/314 gate is login-time only
(the portal-client evidence indicates it is). If the backend turns out to
gate per request, the portal would additionally send a migration-flavor
api-key; either way the app-side contract above is unaffected.- Nuance to raise with the backend team: the iOS token was minted under the
iOS (pro) api-key, while the portal WebView sends the portal api-key —
confirm the backend does not require the bearer token's flavor to match the
api-keyheader's flavor on each request.
- Nuance to raise with the backend team: the iOS token was minted under the
iOS (pro) api-key, while the portal WebView sends the portal api-key —
confirm the backend does not require the bearer token's flavor to match the
First steps for the iOS engineer
- Find how the app currently stores/reads its bearer token, and how it presents modal WebViews (if at all).
- Confirm the
/migrateURL per environment with the portal team. - Implement the entry point +
WKWebView+.atDocumentStartinjection above, then (optionally) theenodeMigrationmessage handler for auto-dismiss.