From 17cfc4cc0b604f73479ca9431725d5512b913a8f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 28 Jul 2026 10:25:48 +0100 Subject: [PATCH] docs(native): point the three native runbooks at mono The Android, iOS and demo-mode runbooks were consolidated into mono at engineering/native/ so there is one source of truth. The copies here had already drifted and now state wrong facts (Capgo deploy triggers, a secret name that no longer exists, the wrong Sumsub plugin package), so a reader landing on them gets misled. Kept as pointers rather than deleted because three files in the release pipeline still reference these paths. Paths are repo-relative, not github.com URLs, so they also resolve for readers who only have a partial copy of mono. --- docs/DEMO-MODE.md | 104 +------------ docs/NATIVE-RELEASE-IOS.md | 165 +------------------- docs/NATIVE-RELEASE.md | 306 +------------------------------------ 3 files changed, 7 insertions(+), 568 deletions(-) diff --git a/docs/DEMO-MODE.md b/docs/DEMO-MODE.md index c130250c4c..ae15fe53e3 100644 --- a/docs/DEMO-MODE.md +++ b/docs/DEMO-MODE.md @@ -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 ` 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. diff --git a/docs/NATIVE-RELEASE-IOS.md b/docs/NATIVE-RELEASE-IOS.md index c2b25fe8d4..2ba98091d8 100644 --- a/docs/NATIVE-RELEASE-IOS.md +++ b/docs/NATIVE-RELEASE-IOS.md @@ -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. diff --git a/docs/NATIVE-RELEASE.md b/docs/NATIVE-RELEASE.md index da469da3d5..c1bdad9fe0 100644 --- a/docs/NATIVE-RELEASE.md +++ b/docs/NATIVE-RELEASE.md @@ -1,307 +1,7 @@ # Native (Android) — Local Dev, Release & Play Review -How to run the app locally, build/sign/ship it, and get it through Play review. +This now lives in the mono repo at `engineering/native/release-android.md`. -> **Architecture:** Capacitor 8 wrapping a static export of the Next.js app — one -> codebase, web + Android (iOS in progress, see §11). Key files: -> `scripts/native-build.js`, `scripts/native-release.sh`, `capacitor.config.ts`, -> `next.config.native.js`. +Local dev and emulator setup now live in `engineering/native/agency-onboarding.md`. ---- - -## 1. Toolchain (must match, or builds fail) - -| Tool | Version | Why | -|------|---------|-----| -| **Node** | **22.x** | `@sentry/profiling-node` ships no binary for Node 25; the API crashes on boot under 25. Use `node@22` (nvm `v22.x` or `brew install node@22`). | -| **JDK** | **17** | Capacitor-android compiles at 21; the app/AGP baseline is 17. `android/build.gradle` forces Java 17 across all modules. | -| **pnpm** | 10 | `corepack enable` | -| **PostgreSQL** | 16 (14 works locally) | backend | -| Xcode / CocoaPods | 26+ / latest | iOS only (§11) | - -Clone with submodules — the build needs `src/content`: -```bash -git clone --recurse-submodules https://github.com/peanutprotocol/peanut-ui -``` - ---- - -## 2. Branches & merge order - -The native work is split into focused branches. **Merge order matters** — the -build-reliability branch is a hard prerequisite: - -1. **`fix/native-build-reliability`** — Java 17 force + `card-comparison.ts` - `'use server'` removal (a Server Action breaks `output: 'export'`) + cleartext - network config. **Without this the static export and Gradle build fail**, so it - must land first. -2. **`fix/native-passkey-reliability`** — silent "Set it up" fix + multi-account - signing (PR #2189 re-applied). -3. **`feat/native-review-readiness`** — reviewer/demo mode, build guard, versionCode - wiring, plugins (haptics/keyboard), `native:release`, CI release, this doc. -4. **`peanut-api-ts` `feat/demo-reviewer-invite`** — the `demo` code + reviewer seed; - deploy alongside #3 so reviewer access works. -5. **`feat/native-ios`** — iOS platform (in progress). - ---- - -## 3. Run it all locally (sandbox / testnet) - -### Backend → `peanut-api-ts` -```bash -# Postgres role + db (one-time). On macOS Homebrew the superuser is your user, -# not `postgres`, so: createuser/createdb directly (DATABASE_URL → peanut_dev). -npx prisma generate --sql # needs the DB up (typed SQL client) -npx prisma migrate deploy -npx tsx scripts/seed-dev-system-users.ts -npx tsx scripts/seed-reviewer-user.ts # seeds the `demo` → `reviewer` inviter -npx tsx scripts/seed-rails.ts # rails — flows 400 without this -PORT=5001 pnpm dev # see port note below -curl localhost:5001/healthz # {"status":"healthy","dbConnected":true} -``` -**Local gotchas (this machine):** -- **Port 5000 is taken by macOS AirPlay Receiver** (`ControlCenter`). Either turn it - off (System Settings → General → AirDrop & Handoff → AirPlay Receiver) to use 5000, - or run on another port (`PORT=5001`) and point the app at it. -- **Run with Node 22** (`PATH="$(brew --prefix node@22)/bin:$PATH" pnpm dev`). -- **`engineering/qa/` dev-cheat imports**: `src/routes/dev/cheats.ts` dynamically - imports a QA harness that only exists in the full monorepo. In a standalone - checkout, drop stub `.mjs` files at `../engineering/qa/lib/{factories/*,zerodev}.mjs` - so esbuild resolves them (the `/dev/cheats` endpoints are unused by the demo flow). -- **`PERK_WALLET_PRIVATE_KEY`** must be set in `.env` (startup inits a perk-wallet - cache). A dummy `0x`+64-hex key is fine for local. - -### App → Android emulator against the local backend -The native shell talks to `NEXT_PUBLIC_BASE_URL` (defaults to prod `peanut.me`). To -hit the **local** backend from the emulator, build with the host alias `10.0.2.2`: -```bash -# peanut-ui/.env.production.local -NEXT_PUBLIC_BASE_URL=http://10.0.2.2:5001 -NEXT_PUBLIC_PEANUT_API_URL=http://10.0.2.2:5001 -NEXT_PUBLIC_NATIVE_RP_ID=peanut.me -NEXT_PUBLIC_CAPACITOR_BUILD=true -``` -Cleartext to `10.0.2.2`/`localhost` is already permitted (`network_security_config.xml` -+ manifest, on `fix/native-build-reliability`; scoped so production https is untouched). -```bash -node scripts/native-build.js -npx cap sync android -npx cap run android --target # or: cd android && ./gradlew assembleDebug && adb install -r -``` -> **Debug-build passkey caveat:** passkey registration verifies the app's signing -> cert against `peanut.me/.well-known/assetlinks.json`. A local **debug** keystore is -> usually not among the listed fingerprints, so passkey creation may fail on the -> emulator. The landing/ribbon and the `demo` invite validation work regardless; for -> full passkey flow use a build signed with a registered key. - ---- - -## 4. Reviewer access (the May-18 rejection fix) - -Invite-only + passkey-only is why the reviewer's access "didn't work". There's now a -**reviewer/demo mode** entered with a single code. - -- Reviewer enters invite code **`demo`** → backend maps it to the `reviewer` inviter - (`PROD/STAGING_SPECIAL_INVITE_CODES_MAP` in `peanut-api-ts/src/utils/invite.ts`). -- The native client recognizes `demo` (`src/utils/reviewer.ts`) and: overlays - pre-filled balance + history (no empty states), **skips KYC**, and **simulates** - send/pay/withdraw (no real funds / on-chain tx — safe on mainnet). -- The reviewer still creates a **real passkey** — the core mechanic review needs to see. - -**Seed the inviter** per environment (idempotent; localhost self-heals): -```bash -npx tsx scripts/seed-reviewer-user.ts # in peanut-api-ts, DATABASE_URL → target env -``` -**Play Console → App content → App access:** declare restricted, instructions: "Enter -invite code `demo`, tap Continue, create a passkey when prompted; you'll land on a -populated demo wallet. No username/password." (Passwordless — the code is the access.) - ---- - -## 5. Passkey reliability fixes (`fix/native-passkey-reliability`) - -- **Silent "Set it up":** `passkeyPreflight.ts` now queries the native plugin's - `isSupported()`; `SetupPasskey.tsx` re-checks on tap and always surfaces an - actionable message — the button can never silently no-op. -- **Multi-account signing:** native signing is pinned to the kernel's own credential - (`native-webauthn.ts` + `kernelClient.context.tsx`). **Smoke-test on a 2-account - device** before relying on it. - ---- - -## 6. Build the release - -```bash -pnpm native:release # derive version → native-build → cap sync → bundleRelease -# → android/app/build/outputs/bundle/release/app-release.aab -``` -- **Anti-rot guard:** `native-build.js` fails loudly if a new server-only route - (route handler / `force-dynamic`) isn't in `ITEMS_TO_DISABLE`. Fix = add it there - (web-only) or give the page `generateStaticParams`. -- **Versioning** (`android/app/build.gradle`, zero manual edits): - - `versionName` ← `ANDROID_VERSION_NAME` env, else `package.json` `version`. - - `versionCode` ← `ANDROID_VERSION_CODE` env, else git commit count; floored at 2 - (rejected first upload was code 1). CI passes `10000 + github.run_number`. - - **CI is the only authoritative versionCode source.** Local builds derive the code - from git commit count, which can collide with or fall behind codes already on Play - (Play rejects duplicates and non-increasing codes). Never upload a locally built - AAB; use the workflow. -- Overrides: `ANDROID_VERSION_NAME=1.0.0 ANDROID_VERSION_CODE=9000 pnpm native:release`. -- **Headers note:** `vercel.json` headers (CSP, HSTS, …) apply to the Vercel web - deployment only — the native static export is served from the app bundle and ships - no HTTP headers, so nothing there affects (or protects) the WebView. - ---- - -## 7. Signing, keystore & secret management - -**Play App Signing is enabled** → Google holds the real signing key; the local -keystore is only the **upload** key. A loss is recoverable (upload-key reset, ~days); -a leak is bounded by Play review + account 2FA. Still treat it as a secret. - -- **Store it in a team secret manager** (1Password/Vault/cloud Secret Manager): - the keystore **base64-encoded** + `storePassword` / `keyPassword` / `keyAlias`. - Never in git, Slack, or a single laptop. -- **Recovery:** Play Console → App integrity → request upload-key reset, then upload a - new upload certificate. Document who can do this. -- **Passkey coupling:** users get the binary re-signed with the **Play App Signing** - cert, so that SHA-256 must be in `public/.well-known/assetlinks.json` (3 fingerprints - listed — confirm the Play App Signing one is present). **Rotating keys requires - updating `assetlinks.json` or passkey sign-in breaks.** -- **rpId sync set:** the passkey rpId (`peanut.me`) is hardcoded in several places that - must change together — a miss breaks passkey creation silently: - 1. `capacitor.config.ts` (`CapacitorPasskey.origin` / `domains`) - 2. `android/app/src/main/res/values/capacitor-passkey.xml` (asset-statements URL) - 3. `.github/workflows/android-release.yml` (`NEXT_PUBLIC_NATIVE_RP_ID`) - 4. `public/.well-known/assetlinks.json` (served from the rpId domain) -- Local signing reads `android/keystore.properties` (gitignored): - ```properties - storeFile=../peanut-release.keystore - storePassword=… - keyAlias=peanut - keyPassword=… - ``` - ---- - -## 8. CI release pipeline (`.github/workflows/android-release.yml`) - -Removes the "release only builds on one laptop" gap — keystore lives as CI secrets, -the build is reproducible, the AAB lands on a Play track. - -- **Trigger:** push tag `vX.Y.Z`, or manual dispatch (pick a track). -- **Flow:** checkout (submodules) → JDK 17 + Node 22 → install → decode keystore + - write `keystore.properties` → write prod `NEXT_PUBLIC_*` → `pnpm native:release` - (`versionCode = github.run_number`) → upload AAB to Play (`internal` by default). -- **Gate** with a `production` GitHub Environment + required reviewers. -- **Track promotion:** internal → closed/beta → production with **staged rollout** - (`status: inProgress` + `userFraction: 0.1`, promote after metrics look clean). - -**Required repo secrets:** - -| Secret | What | -|--------|------| -| `ANDROID_KEYSTORE_BASE64` | `base64 -w0 peanut-release.keystore` | -| `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | signing creds | -| `PLAY_SERVICE_ACCOUNT_JSON` | Google Play Developer API service account (least-priv "Release manager") | -| `SUBMODULE_TOKEN` | read access to the `src/content` submodule | -| `CAPGO_API_KEY` | OTA (already used by `capgo-deploy.yml`) | -| prod `NEXT_PUBLIC_*` | the values the static export bakes in (OneSignal, Sentry, chain, …) | - -> Housekeeping: the secret is named `NEXT_PUBLIC_SENTRY_DSN` but a Sentry DSN is public -> by design (it ships in every web bundle) — the `secrets.*` storage is convention, not -> confidentiality. If renaming to `SENTRY_DSN` for clarity, update the reference in -> `android-release.yml` in the same change or builds bake an empty DSN. - -> Prereq: `fix/native-build-reliability` must be merged or `native:release` won't build. - ---- - -## 9. OTA updates (Capgo) - -`capgo-deploy.yml` builds the static export and uploads on push: `main` → `production`, -`dev` → `staging`; manual dispatch picks the channel. - -- **Configure the `production` channel** in the Capgo dashboard and bind it to the prod - app (the workflow pushes to it; the channel must exist). -- **Native-version gating:** `--auto-min-update-version` (already set) keeps a JS bundle - built against new plugins off older native shells. **Bump the native version whenever - you change plugins/native code**, then ship that via Play — OTA can't. -- **Staged rollout:** roll production OTA to ~10% → watch Sentry/crash + error rates → - 100%. Don't 100% every merge. -- **Rollback** is configured in `capacitor.config.ts` (`appReadyTimeout: 15000` + - `autoDeleteFailed` + `autoDeletePrevious`): a bundle that never calls - `notifyAppReady()` auto-reverts. **Verify once** with a deliberately-broken bundle. -- **Boundary:** OTA ships web assets only. New plugins, Gradle, permissions, versionCode - → Play release. - ---- - -## 10. Pre-submission verification - -1. Local stack up (backend + seeds), `demo` validates. -2. `pnpm native:release` produces a signed AAB with `versionCode ≥ 2`. -3. Reviewer-mode E2E on a device: invite `demo` → passkey → Home/History show demo data - → KYC skipped → a send reaches a simulated success (no on-chain tx). -4. Passkey silent-failure: no Google account / outdated Play Services → actionable error, - never a no-op. -5. Multi-account smoke test: two accounts on one device → both sign valid signatures. -6. `pnpm test:unit:ci` green; `pnpm typecheck` clean. - ---- - -## 11. Push notifications (OneSignal / FCM) - -Push is delivered through the same OneSignal app as web — the device links to the user -via `OneSignal.login(userId)`, so 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()`); native uses -`@onesignal/capacitor-plugin`, autolinked into Gradle by `npx cap sync android`. - -**Already wired (committed):** -- `@onesignal/capacitor-plugin` in `package.json`; the plugin's FCM/OneSignal Gradle - deps autolink on `cap sync`. -- `AndroidManifest.xml`: `POST_NOTIFICATIONS` (the Android 13+ runtime prompt, driven by - the plugin's `requestPermission()`). -- `android/app/build.gradle` already conditionally applies the `google-services` plugin - **only when `google-services.json` is present** — its absence disables push but never - fails the build. -- `scripts/native-build.js` warns when `NEXT_PUBLIC_ONESIGNAL_APP_ID` is unset (the app id - is inlined into the static bundle; without it the native SDK can't initialize). - -**Provider setup (do once, no code):** -1. **Firebase:** create/locate the Firebase project for `me.peanut.wallet`, download - `google-services.json`, place it at `android/app/google-services.json` (gitignored). -2. **OneSignal dashboard** → the existing app → **Google Android (FCM)** platform → - upload the **FCM v1 service account JSON** (Firebase → Project settings → Service - accounts → Generate private key). -3. **CI:** set the `ANDROID_GOOGLE_SERVICES_JSON` secret to `base64 -w0 google-services.json`. - The `Decode google-services.json` step in `android-release.yml` writes it before the - build (and skips gracefully when unset). - -**Verify (real device/emulator with Play Services):** -`node scripts/native-build.js && npx cap sync android && ./gradlew assembleDebug`, install, -accept the prompt (surfaced via the existing `SetupNotificationsModal`), confirm a -subscription appears under the user's `external_id` in OneSignal, then send a test push. - ---- - -## 12. iOS release - -The iOS release pipeline (`.github/workflows/ios-release.yml`) is maintained on its own -branch (`feat/ci-ios`), separate from this Android runbook. It mirrors the Android lane's -no-fastlane style: an Apple Distribution cert + App Store provisioning profile stored as -CI secrets, `xcodebuild` archive/export, and upload to TestFlight via -`apple-actions/upload-testflight-build`. - -See **`docs/NATIVE-RELEASE-IOS.md`** (on `feat/ci-ios`) for the full iOS pipeline, -one-time signing-material setup, secrets table, and manual App Store promotion. - ---- - -## 13. Play submission - -- Upload to a **closed/internal** track first; dogfood the reviewer flow end-to-end. -- Re-check Data safety, permissions (camera for QR/KYC), content rating, and the App - access instructions in §4. -- Promote to **production** review only after the internal track passes. +Moved so the native runbooks have a single source of truth.