Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 2 additions & 102 deletions docs/DEMO-MODE.md
Original file line number Diff line number Diff line change
@@ -1,105 +1,5 @@
# Demo mode

App-Store reviewer demo: a fully navigable, **native-only**, client-side walkthrough with
believable data and **no backend**. There is no demo account, no `/demo-login`, no real
reads or writes — each reviewer gets an isolated, deterministic session.
This now lives in the mono repo at `engineering/native/demo-mode.md`.

## How it works

1. **Entry.** The invite code `demo` (case-insensitive) flips demo mode on. See
[`src/utils/demo.ts`](../src/utils/demo.ts): `enableDemoMode()` sets an in-memory flag
plus a `localStorage` key. `isDemoMode()` returns `true` only when **both**
`isCapacitor()` is true **and** the flag is set — so it is always `false` on web.

2. **Network interception.** Every API request funnels through `callApi` in
[`src/utils/api-fetch.ts`](../src/utils/api-fetch.ts) (both `apiFetch` and `serverFetch`
are aliases of it). The first line short-circuits in demo mode:

```ts
if (isDemoMode()) return demoRespond(path, options)
```

so no request ever reaches the network and no `authorization` header is required.

3. **The router.** [`src/utils/demo-api.ts`](../src/utils/demo-api.ts) — `demoRespond(path, options)`
strips the query string, matches `{method, path}` against an ordered table (literal paths
before `:param` paths), and returns `new Response(JSON.stringify(data), { status: 200 })`.
Unmatched routes hit a **shape-aware fallback** (`[]` for collection-ish paths, `{}`
otherwise) so a consumer never throws on `undefined.map`, and log
`[demo-api] unmocked <method> <path>` in dev so QA can spot gaps.

4. **Fixtures.** All demo data lives in
[`src/constants/demo-data.ts`](../src/constants/demo-data.ts): `DEMO_USER`,
`DEMO_CONTACTS`, `DEMO_HISTORY_ENTRIES`, `DEMO_LIMITS`, `DEMO_BALANCE_UNITS`,
`DEMO_ADDRESS`. `demo-api.ts` composes its responses from these plus inline canned objects.

5. **WebSocket.** `getWebSocketInstance()` in
[`src/services/websocket.ts`](../src/services/websocket.ts) returns `null` in demo mode
(callers already handle `null`), avoiding the mixed-content `wss://` failure.

6. **No real money.** UserOps / passkey signing are hard-stopped elsewhere
(`kernelClient.context`, `useZeroDev`, `useSpendBundle`), so even "enabled" rails and
completed flows are purely simulated.

### Relationship to the per-hook demo branches

A few hooks short-circuit demo mode *before* `callApi` and source their values from
`demo-data.ts`: `useUserQuery` (`/users/me` + a synchronous Redux seed that fixes the
no-bounce race), `useTransactionHistory` (history), `useWallet` (balance), `useCapabilities`.
These remain the primary path; the interceptor is the **backstop** that also covers
`/users/me`, `/users/history`, etc., so any code path that reaches the network still gets
synthetic data instead of a 401.

> Exception: `sendLinksApi.create` posts multipart `FormData` via `fetchWithSentry`
> directly (bypassing `callApi`), so it calls `demoRespond` explicitly in demo mode.

## Adding a mock

1. Find the endpoint (path + method + expected response shape) — grep the `serverFetch(` /
`apiFetch(` call site, or check `src/types/api.openapi.json`.
2. Add a row to the `ROUTES` table in `demo-api.ts`. Use `:param` segments for path params;
keep literal paths above `:param` paths that could also match.
3. If the response is real *data* (not a one-off canned success), add a fixture to
`demo-data.ts` and reference it, so data has one home.
4. Match the consumer's expected envelope exactly (e.g. `{ items, nextCursor }` for
notifications, a bare `[]` array for `/users/:id/rewards`). When unsure, the consumer's
`.json()` usage is the source of truth.

## QA screen-walk checklist

Enter invite code `demo` on a native build, then visit each screen and confirm: **no auth
errors, populated-or-empty states only, headline flows reach a simulated success screen.**
Watch the console for `[demo-api] unmocked` and close any gap.

- [ ] Home (balance, latest activity)
- [ ] Send → Contacts (alice/bob/carol/dave populated) and Send link
- [ ] Request payment
- [ ] Add money — US, BR, AR (quote → review → simulated success)
- [ ] Withdraw / cash out — US, BR, AR (incl. Bridge ToS step)
- [ ] QR pay (scan → complete)
- [ ] Activity / history (infinite scroll) + a receipt detail
- [ ] Profile + sub-pages, Settings
- [ ] Rewards / Points
- [ ] Card
- [ ] Notifications
- [ ] Support

## Safety / tests

Hard boundaries the demo session cannot cross:

- **Web-inert.** `isDemoMode()` is `false` outside the Capacitor shell, no matter what
flags are set — the demo API layer is unreachable from the web app.
- **No real funds.** Sends are simulated; UserOps are hard-stopped before signing, so
nothing can reach a chain.
- **No real backend session.** Every API call short-circuits to synthetic responses;
no JWT exists and KYC is skipped by construction.
- **No push / tracking identity.** `useNotifications` skips OneSignal init entirely in
demo mode — no subscription is created and no `external_id` login is attempted.
- **No websocket.** Demo sessions never open a live connection; consumers must handle
the absent socket.

`src/utils/__tests__/demo-api.test.ts` covers routing, param extraction, and the
shape-aware fallback; `src/utils/__tests__/demo.test.ts` locks the web-inert guarantee
(session flag, direct localStorage flag, and disable/cleanup paths). Run `pnpm test`
and `pnpm typecheck`.
Moved so the native runbooks have a single source of truth.
165 changes: 2 additions & 163 deletions docs/NATIVE-RELEASE-IOS.md
Original file line number Diff line number Diff line change
@@ -1,166 +1,5 @@
# Native (iOS) — CI Release to TestFlight

How the iOS app is built, signed, and shipped to TestFlight from CI. This mirrors
the Android release pipeline (`docs/NATIVE-RELEASE.md`) but for iOS, and uses the
same **no-fastlane** style: signing material lives as CI secrets (the iOS analogue
of the Android keystore-as-secret), the build is reproducible, and the IPA lands on
TestFlight for manual App Store promotion.
This now lives in the mono repo at `engineering/native/release-ios.md`.

> **Architecture:** Capacitor 8 wrapping a static export of the Next.js app. The iOS
> project is standard Capacitor (scheme `App`, workspace `ios/App/App.xcworkspace`,
> bundle `me.peanut.wallet`, SPM, deploy target 15.0, entitlements
> `App/App.entitlements`). `Info.plist` reads `$(CURRENT_PROJECT_VERSION)` /
> `$(MARKETING_VERSION)`.

---

## 1. The pipeline (`.github/workflows/ios-release.yml`)

- **Trigger:** push tag `vX.Y.Z`, or manual dispatch (optional `versionName` input).
- **Runner:** `macos-15`, gated by the `production` GitHub Environment (required reviewers).
- **Flow:** checkout (submodules) → Xcode `latest-stable` + Node 22 + pnpm 10 →
`pnpm install` → write prod `NEXT_PUBLIC_*` (same block as `android-release.yml`) →
`node scripts/native-build.js && npx cap sync ios` (+ optional
`scripts/native-ios-postsync.js` if present) → **import cert**
(`apple-actions/import-codesign-certs`) → **install profile** (decode the base64
secret, read its UUID/Name, drop into `~/Library/MobileDevice/Provisioning Profiles/`)
→ **archive + export** (`xcodebuild archive` then `-exportArchive` with a generated
`ExportOptions.plist`) → **upload** (`apple-actions/upload-testflight-build`) → IPA
artifact (`build/ios/*.ipa`).
- **Signing:** the project default is **Automatic**; the archive step overrides it for
that build via command-line build settings (`CODE_SIGN_STYLE=Manual`,
`DEVELOPMENT_TEAM`, `CODE_SIGN_IDENTITY="Apple Distribution"`,
`PROVISIONING_PROFILE_SPECIFIER`). `CURRENT_PROJECT_VERSION` is always the CI run
number; `MARKETING_VERSION` is overridden only when `versionName` is supplied
(`Info.plist` reads both via `$(...)`).

---

## 2. One-time setup (signing material)

CI consumes pre-made signing material — create it once from a machine with Apple
Developer access and store it as repo secrets:

1. **Distribution certificate** — in Xcode (or the Developer portal) create/obtain an
**Apple Distribution** certificate, then export it from Keychain Access as a `.p12`
(with a password). Base64 it:
```bash
base64 -i AppleDistribution.p12 | pbcopy # → IOS_DIST_CERT_P12_BASE64
```
Store the export password as `IOS_DIST_CERT_PASSWORD`.
2. **Provisioning profile** — in the Developer portal create an **App Store** profile
for `me.peanut.wallet` that includes the **Associated Domains** capability (it backs
`webcredentials:peanut.me` in `App/App.entitlements` — passkeys break without it).
Download the `.mobileprovision` and base64 it:
```bash
base64 -i me_peanut_wallet_appstore.mobileprovision | pbcopy # → IOS_PROVISIONING_PROFILE_BASE64
```
The workflow reads the profile's name from the file itself — no separate name secret.
3. **App Store Connect API key** — create an API key (Admin / App Manager) in App Store
Connect; store the issuer id, key id, and the **raw `.p8` contents** as the secrets
below.

> **Rotation:** the distribution certificate expires ~yearly and the profile
> expires / needs re-issuing when the cert or capabilities change. When that happens,
> regenerate the cert/profile, re-export, re-base64, and update `IOS_DIST_CERT_*` /
> `IOS_PROVISIONING_PROFILE_BASE64`. (This manual step is the trade-off for not using
> fastlane `match`.)

---

## 3. Manual App Store promotion

The pipeline stops at **TestFlight** (upload is automatic). After the build finishes
processing, promote it to the App Store **manually** in App Store Connect (submit for
review) once TestFlight validation looks clean — the iOS analogue of the Play track
promotion on the Android side.

---

## 4. Required repo secrets

| Secret | What |
|--------|------|
| `ASC_KEY_ID` | App Store Connect API key id |
| `ASC_ISSUER_ID` | App Store Connect API issuer id |
| `ASC_KEY_CONTENT` | the **raw `.p8` contents** of the ASC API private key (paste the PEM, incl. `-----BEGIN PRIVATE KEY-----`) |
| `APPLE_TEAM_ID` | Apple Developer team id |
| `IOS_DIST_CERT_P12_BASE64` | Apple Distribution cert exported as `.p12`, base64-encoded |
| `IOS_DIST_CERT_PASSWORD` | password set when exporting the `.p12` |
| `IOS_PROVISIONING_PROFILE_BASE64` | App Store `.mobileprovision` for `me.peanut.wallet`, base64-encoded |
| `SUBMODULE_TOKEN` | read access to the `src/content` submodule (shared with Android) |

Plus the production `NEXT_PUBLIC_*` Variables/Secrets the static export bakes in — the
`Production web env` step is identical to `android-release.yml`, so both platforms
produce the same `.env.production.local`.

---

## 5. Prerequisite — the `ios/` platform must be committed

The `ios/` Capacitor platform must exist on the branch CI builds. It currently lives
**only in the `peanut-ui-ios2` / `peanut-ui-ios` worktrees** — until `ios/` is committed
to the branch this workflow runs on, `npx cap sync ios` (and the whole job) will fail.

---

## 6. Push notifications (OneSignal / APNs)

Push is delivered through the same OneSignal app as web — the device links to the user
via `OneSignal.login(userId)`, so the existing `external_id`-targeted sequences reach
native with no backend or sequence changes. The web/native split lives behind
`src/services/onesignal/` (selected by `isCapacitor()`); the native side is
`@onesignal/capacitor-plugin`, wired into the app target's SPM by `npx cap sync ios`.

### 6a. App target — already committed
- `App.entitlements`: `aps-environment` + App Group `group.me.peanut.wallet.onesignal`.
- `Info.plist`: `UIBackgroundModes` → `remote-notification`.
- `aps-environment` is committed as `development` (local on-device debug builds use the
APNs **sandbox**). **Release/TestFlight builds need `production`** — the App Store
provisioning profile carries production APNs. Either flip the value before an archive
or keep a build-config-specific entitlements file. Watch for a signing mismatch if the
profile and entitlement environments disagree.

### 6b. Notification Service Extension (NSE) — needs a one-time Xcode step
Rich media, badge sync, and confirmed-delivery analytics require an NSE target. The
**source files are committed** under `ios/App/OneSignalNotificationServiceExtension/`
(`NotificationService.swift`, `Info.plist`, `*.entitlements`). The Xcode **target**
itself is not hand-forged into `project.pbxproj` (too error-prone to script blind). Add
it once in Xcode:

1. **File → New → Target → Notification Service Extension.** Name it
`OneSignalNotificationServiceExtension`. Set its deployment target to **iOS 15**
(match the app). Do **not** activate the scheme when prompted.
2. Delete Xcode's generated `NotificationService.swift` / `Info.plist` for the target and
**add the committed files** in `ios/App/OneSignalNotificationServiceExtension/` to the
target instead (or point the target's `INFOPLIST_FILE` / sources at them).
3. **Signing & Capabilities** for the extension target: add the **App Groups** capability
and tick `group.me.peanut.wallet.onesignal` (same group as the app). Set
`CODE_SIGN_ENTITLEMENTS` to the committed `*.entitlements`.
4. **Add the OneSignal extension SPM product to the *extension* target:** File → Add
Package Dependencies → `https://github.com/OneSignal/OneSignal-iOS-SDK` → add the
**`OneSignalExtension`** product **to the extension target only** (the app target gets
OneSignal transitively via the Capacitor plugin — don't double-add).
5. The extension bundle id is `me.peanut.wallet.OneSignalNotificationServiceExtension`;
create a matching **App Store provisioning profile** for it (with the App Group) and
add it to CI signing alongside the app profile.

> After adding the target, commit the resulting `project.pbxproj` (and `Package.resolved`)
> so CI builds it. `npx cap sync ios` regenerates `CapApp-SPM/Package.swift` for the app
> target only and will **not** touch the extension target — the NSE's SPM dependency is
> managed directly on the target in Xcode.

### 6c. Provider setup (OneSignal dashboard — do once, no code)
- Create an **APNs `.p8` auth key** in the Apple Developer portal (Keys → enable Apple
Push Notifications service). Note the **Key ID** and your **Team ID**.
- In the OneSignal dashboard → the existing app → **Apple iOS (APNs)** platform → upload
the `.p8`, Key ID, Team ID, and bundle id `me.peanut.wallet`.
- Enable **Push Notifications** on the `me.peanut.wallet` App ID, and make sure the App
Store provisioning profile(s) include the push entitlement.

### 6d. Verify (real device — APNs doesn't work on the simulator)
1. `node scripts/native-build.js && npx cap sync ios`, open `ios/App/App.xcworkspace`,
run on a physical device.
2. Accept the permission prompt (surfaced via the existing `SetupNotificationsModal`),
confirm a subscription appears under the user's `external_id` in OneSignal.
3. Send a test push with an image and confirm the NSE renders the rich notification.
Moved so the native runbooks have a single source of truth.
Loading
Loading