Status: Draft Owner: software-architect Date: 2026-04-22 Parent epic: #78 — Exploration Platform for Individual Traders Related ADRs: 0004, 0005, 0006
v1 ships: portfolio view, positions, order status, paper-trading order entry, push alerts for fills and risk events, passkey sign-in.
v2+ (not in scope here): live-order entry from iOS, options chain UI, charting, Face ID step-up for live-mode toggle, biometric-gated watchlists sync.
Invariants that apply: no stored credentials, passkey/WebAuthn only, email-only contact, GDPR-by-default, paper-first gating, audit trail on every money/permission state change.
See ADR 0004 for the full decision. Short rationale: the AuthenticationServices framework is the only sanctioned path to platform passkeys on iOS; it is a native-only API. Wrapping it in React Native or Capacitor adds bridging complexity with no UX or code-sharing gain given that Antlers (the web frontend) is not a React Native app. PWA wrappers cannot call AuthenticationServices at all. SwiftUI is the current Apple-supported UI framework; its learning curve is lower than UIKit for greenfield work and produces smaller, auditable binaries.
Invariant respected: passkey/WebAuthn only; no stored credentials.
iOS 16+ exposes passkeys through ASAuthorizationController + ASAuthorizationPlatformPublicKeyCredentialProvider. Credentials sync via iCloud Keychain (encrypted, Apple holds no plaintext). The RP ID is raxx.app — the same RP used by the web app (see ADR 0005 and §open questions). This means a passkey created on the web can be asserted from the iOS app and vice versa, which is the correct user experience.
The auth handshake:
sequenceDiagram
participant U as User (iOS)
participant App as Raxx iOS
participant AS as AuthenticationServices (OS)
participant R as Raptor (api.raxx.app)
U->>App: Tap "Sign in"
App->>R: POST /api/auth/login/options
R-->>App: PublicKeyCredentialRequestOptions (challenge)
App->>AS: ASAuthorizationController.performRequests()
AS->>U: Face ID / Touch ID / iCloud Keychain picker
U-->>AS: Biometric confirmed
AS-->>App: ASAuthorizationPublicKeyCredentialAssertion
App->>R: POST /api/auth/login/verify {assertion}
R-->>App: Set short-lived API token (15 min, per auth.md §6)
App->>App: Store token in Keychain (not credential — opaque session token)
The app stores the opaque API token in the iOS Keychain (not iCloud-synced) with kSecAttrAccessibleWhenUnlockedThisDeviceOnly. It is not a passkey private key — it is the same short-lived session token that the web uses, and is revocable server-side immediately. This does not violate the no-stored-credentials invariant.
Step-up (e.g. confirming a paper order above a threshold) calls performRequests() again before sending the request to Raptor.
Direct to api.raxx.app. No iOS-specific API layer or BFF in v1. All existing /api/* endpoints are consumed as-is. If iOS-specific endpoints become necessary (e.g. APNs device token registration — see below), they are added to Raptor as thin additions, not a separate service.
See ADR 0006. When the device has no network the app presents cached state from the last successful fetch — portfolio snapshot, positions, last known fill list — with a visible "Last updated at HH:MM" banner. All interactive controls (order entry, settings changes) are disabled with an explanatory prompt. The app does not queue orders for offline submission; trading requires real-time data and a connected Raptor. This is "read-only cache" posture, not "offline-capable."
Invariant respected: email is the single contact channel. Push notifications are an in-app engagement feature, not a contact channel. They require the user's explicit iOS permission grant.
APNs setup requires:
1. An Apple Developer account with the push notification entitlement for the app bundle ID.
2. A new Raptor endpoint POST /api/devices/apns that registers a device token against an authenticated user. The token is stored in a new apns_device_tokens table (user_id, token, created_at, revoked_at) — not PII per se, but subject to GDPR erasure on DSR.
3. Raptor dispatches APNs pushes via Apple's HTTP/2 APNs API using a private key stored in the secret store (rotatable, never in code).
v1 event scope (narrow): paper fill confirmed, paper order rejected, session about to expire (15 min warning), breach/security alert from the platform. Live-trading alerts are v2 (paper-first gate blocks live execution from iOS in v1 anyway).
Silent background refreshes (background app refresh entitlement) are acceptable for portfolio data; no location or health entitlements are needed.
These are known friction points to anticipate, not solved here:
All original blocker questions resolved by the user:
raxx.app confirmed as the single production RP ID — shared across web, iOS, and console. Immutable once users start registering passkeys. See ADR 0005.