diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml new file mode 100644 index 0000000..cf588d5 --- /dev/null +++ b/.github/workflows/e2e-ios.yml @@ -0,0 +1,270 @@ +# Phase-0 iOS E2E harness (Maestro launch smoke). +# +# Proves end-to-end: EAS dev-client build → macOS runner → booted sim → hermetic +# backend on localhost (api-server + chat-ws + fresh Neon branch + seed) → auth → +# chat-list mounts. See apps/mobile/.maestro/launch-smoke.yaml for what it asserts +# and why it stops at the empty list (E2EE chats can't be seeded — Phase 1 adds an +# on-device chat-establishment flow before chat-smoke can run). +# +# ── Why hermetic, not staging ──────────────────────────────────────────────── +# The app resolves its backend from BUILD-TIME EXPO_PUBLIC_API_URL / +# EXPO_PUBLIC_CHAT_WS_URL, falling back to http://localhost:3001 / ws://localhost:8787 +# (apps/mobile/lib/api/url.ts). We build the dev artifact with NO env baked, so at +# runtime the app hits localhost — which on the sim maps to the runner host, where +# we boot the backend. Deterministic, disposable, no shared-env pollution. +# +# ── Required repo secrets (this stays red until all are set) ────────────────── +# EXPO_TOKEN — EAS build auth. Also run `eas init` once so app.json gets +# extra.eas.projectId (not present yet — prerequisite). +# NEON_API_KEY — provision/destroy the per-run Neon branch. +# NEON_PROJECT_ID — the Neon project to branch from. +# The chat-ws↔api HMAC is generated per-run below and shared between both +# processes, so it needs no secret. +# +# ── UNVERIFIED from authoring env — confirm on first real run ──────────────── +# 1. SST `Resource.ChatWsHmacSecret.value` under bare `wrangler dev` / `tsx`: +# the sst SDK reads process.env.SST_RESOURCE_ (JSON). We inject that +# for both processes below. If the SDK also demands SST_RESOURCE_App, add it. +# 2. Neon pooled vs direct URL: migrate/seed use the DIRECT url (Prisma binary +# engine, TCP); api/chat-ws use the POOLED url (Neon HTTP driver). Verify the +# neonctl connection-string flags below return the shapes your driver expects. +# 3. Neon default database-name/role-name — set explicitly if your project's +# defaults differ from what neonctl infers. +# 4. api-server extra env (BETTER_AUTH_SECRET etc.): the .env is not in CI, so +# we pass a minimal dev env inline. Add any var services/api/src/lib/auth.ts +# requires at boot that isn't here. + +name: E2E iOS (Maestro) + +on: + workflow_dispatch: + # Cost-gated: macOS minutes + EAS build. Only on PRs that touch the app or the + # backend surface the smoke exercises. Widen once it's proven stable. + pull_request: + paths: + - "apps/mobile/**" + - "services/api/**" + - "services/chat-ws/**" + - "packages/database/**" + - ".github/workflows/e2e-ios.yml" + +concurrency: + group: e2e-ios-${{ github.ref }} + cancel-in-progress: true + +env: + TURBO_TELEMETRY_DISABLED: 1 + APP_ID: io.sessions.app # app.json ios.bundleIdentifier + API_PORT: "3001" + WS_PORT: "8787" + +jobs: + e2e-ios: + name: Launch smoke + runs-on: macos-15 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # api-server imports @repo/chat-mls-core (mlsRouter), whose native binding + # is loaded at module init — the server will not boot without index.node. + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/chat-mls-core/node + cache-on-failure: true + + - name: Build MLS native core + run: pnpm --filter @repo/chat-mls-core build:node + + # ── Shared per-run HMAC (chat-ws → api /internal/chat/verify-session) ───── + # Both processes read Resource.ChatWsHmacSecret.value via the sst SDK, which + # resolves from SST_RESOURCE_ChatWsHmacSecret (JSON). Same value → the + # verify-session callback authenticates. + - name: Generate dev HMAC secret + run: | + HMAC="$(openssl rand -hex 32)" + echo "::add-mask::$HMAC" + echo "SST_RESOURCE_ChatWsHmacSecret={\"value\":\"$HMAC\"}" >> "$GITHUB_ENV" + + # ── Neon: fresh branch per run ──────────────────────────────────────────── + - name: Provision Neon branch + id: neon + env: + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }} + run: | + set -euo pipefail + BRANCH="ci-e2e-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + npx neonctl branches create \ + --project-id "$NEON_PROJECT_ID" \ + --name "$BRANCH" --output json > /dev/null + # Direct (TCP) for Prisma migrate/seed; pooled (HTTP driver) for runtime. + DIRECT="$(npx neonctl connection-string "$BRANCH" --project-id "$NEON_PROJECT_ID")" + POOLED="$(npx neonctl connection-string "$BRANCH" --project-id "$NEON_PROJECT_ID" --pooled)" + echo "::add-mask::$DIRECT" + echo "::add-mask::$POOLED" + echo "DIRECT_DATABASE_URL=$DIRECT" >> "$GITHUB_ENV" + echo "POOLED_DATABASE_URL=$POOLED" >> "$GITHUB_ENV" + + - name: Migrate + seed database + # Schema declares both url=env(DATABASE_URL) and directUrl=env(DIRECT_URL). + # Migrate + seed run over the DIRECT (unpooled) url on both — migrations + # require it, and seeding over the pooler risks prepared-statement quirks. + # Runtime api/chat-ws use the POOLED url (Neon HTTP driver) below. + env: + DATABASE_URL: ${{ env.DIRECT_DATABASE_URL }} + DIRECT_URL: ${{ env.DIRECT_DATABASE_URL }} + run: | + pnpm --filter @repo/database db:migrate:deploy + pnpm --filter @repo/database db:seed + + # ── Backend on localhost (same host the sim resolves to) ────────────────── + - name: Start api-server + env: + PORT: ${{ env.API_PORT }} + NODE_ENV: development + DATABASE_URL: ${{ env.POOLED_DATABASE_URL }} + BETTER_AUTH_URL: http://localhost:3001 + BETTER_AUTH_SECRET: ci-dev-better-auth-secret-not-a-real-secret + TRUSTED_ORIGINS: http://localhost:3001 + # SST_RESOURCE_ChatWsHmacSecret is already in $GITHUB_ENV (masked). + run: | + # Bypass the package "dev" script (--env-file=.env, which doesn't exist + # in CI) and run the entrypoint directly with the env above. + nohup pnpm --filter @repo/api-server exec tsx src/server.ts > api.log 2>&1 & + echo "api started (pid $!)" + + - name: Start chat-ws (wrangler dev) + env: + POOLED_DATABASE_URL: ${{ env.POOLED_DATABASE_URL }} + run: | + # wrangler dev reads secrets from .dev.vars (gitignored; CI-only here). + # DATABASE_URL → Neon HTTP driver; SST_RESOURCE_* → sst SDK Resource proxy. + cat > services/chat-ws/.dev.vars < ../../ws.log 2>&1 & ) + echo "chat-ws starting" + + - name: Wait for backend + run: | + set -e + for name_port in "api:${API_PORT}" "ws:${WS_PORT}"; do + name="${name_port%%:*}"; port="${name_port##*:}" + echo "waiting for $name on :$port" + for i in $(seq 1 60); do + if nc -z localhost "$port"; then echo "$name up"; break; fi + if [ "$i" = "60" ]; then + echo "::error::$name never came up on :$port"; cat api.log ws.log || true; exit 1 + fi + sleep 2 + done + done + + # ── EAS simulator build (cloud) → download .app ────────────────────────── + - name: Setup EAS + uses: expo/expo-github-action@v8 + with: + eas-version: 20.5.1 + token: ${{ secrets.EXPO_TOKEN }} + + - name: Build iOS simulator app (EAS) + id: build + working-directory: apps/mobile + run: | + set -euo pipefail + # preview-simulator = RELEASE build with the JS bundle EMBEDDED — no + # dev-client launcher, no Metro needed (a dev-client build boots to + # "No development servers found" in CI). No EXPO_PUBLIC_* baked → the + # `??` fallback in lib/api/url.ts hands back localhost at runtime. + META="$(eas build --profile preview-simulator --platform ios \ + --non-interactive --wait --json)" + URL="$(echo "$META" | jq -r '.[0].artifacts.applicationArchiveUrl')" + echo "artifact=$URL" >> "$GITHUB_OUTPUT" + + - name: Download + extract app + run: | + set -euo pipefail + curl -L -o build.tar.gz "${{ steps.build.outputs.artifact }}" + mkdir -p build && tar -xzf build.tar.gz -C build + APP_PATH="$(find build -maxdepth 2 -name '*.app' -type d | head -n1)" + echo "APP_PATH=$APP_PATH" >> "$GITHUB_ENV" + + # ── Boot sim, install, run Maestro ──────────────────────────────────────── + - name: Boot simulator + install app + run: | + set -euo pipefail + UDID="$(xcrun simctl list devices available | grep -m1 'iPhone 16' \ + | grep -oE '[0-9A-F-]{36}')" + echo "SIM_UDID=$UDID" >> "$GITHUB_ENV" + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b + xcrun simctl install "$UDID" "$APP_PATH" + + - name: Cache Maestro + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-${{ runner.os }}-1.39.0 # pin your version + - name: Install Maestro + run: | + if [ ! -x "$HOME/.maestro/bin/maestro" ]; then + export MAESTRO_VERSION=1.39.0 + curl -Ls "https://get.maestro.mobile.dev" | bash + fi + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + - name: Run launch smoke + env: + MAESTRO_APP_ID: ${{ env.APP_ID }} + run: | + # Pass MAESTRO_APP_ID via --env, not the process environment: pinned + # Maestro (1.39.0) does not interpolate `appId: ${MAESTRO_APP_ID}` + # from the OS env, so it resolved to null ("Unable to launch app null"). + maestro --device "$SIM_UDID" test \ + --env MAESTRO_APP_ID="$MAESTRO_APP_ID" \ + apps/mobile/.maestro/launch-smoke.yaml \ + --format junit --output maestro-report.xml + + - name: Upload Maestro artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: maestro-e2e + path: | + maestro-report.xml + ~/.maestro/tests/** + api.log + ws.log + if-no-files-found: ignore + + # ── Teardown ────────────────────────────────────────────────────────────── + - name: Destroy Neon branch + if: always() + env: + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }} + run: | + npx neonctl branches delete "${{ steps.neon.outputs.branch }}" \ + --project-id "$NEON_PROJECT_ID" || true diff --git a/.github/workflows/native-artifacts.yml b/.github/workflows/native-artifacts.yml new file mode 100644 index 0000000..83eb61b --- /dev/null +++ b/.github/workflows/native-artifacts.yml @@ -0,0 +1,138 @@ +# Producer for gitignored native binaries (Rust → xcframework), consumed by +# scripts/fetch-native-artifacts.sh (EAS eas-build-pre-install hook + CI). +# +# Model — the compiled xcframework is non-reproducible (embedded timestamps + +# build paths, see .gitignore), so CI is the SOLE builder/publisher: it builds, +# uploads the asset to R2 keyed by the Rust SOURCE fingerprint, and commits the +# refreshed checksum manifest + .artifact-key. That published asset therefore +# always matches the committed manifest the consumer verifies against. +# +# Idempotent: if the asset for the current source key already exists on R2, the +# expensive Rust/iOS build is skipped — reruns and non-Rust PR syncs are cheap. +# +# ── Required config ─────────────────────────────────────────────────────────── +# secrets.R2_ACCOUNT_ID Cloudflare account id (R2 S3 endpoint host) +# secrets.R2_ACCESS_KEY_ID R2 access key (Object Read & Write) +# secrets.R2_SECRET_ACCESS_KEY R2 secret key +# vars.R2_BUCKET bucket name, e.g. mortstack-native +# vars.R2_PUBLIC_BASE_URL public-read base, e.g. https://native./mortstack +name: Native artifacts + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "packages/chat-mls-core/Cargo.toml" + - "packages/chat-mls-core/Cargo.lock" + - "packages/chat-mls-core/src/**" + - "packages/chat-mls-core/scripts/build-mls.sh" + - ".github/workflows/native-artifacts.yml" + pull_request: + paths: + - "packages/chat-mls-core/Cargo.toml" + - "packages/chat-mls-core/Cargo.lock" + - "packages/chat-mls-core/src/**" + - "packages/chat-mls-core/scripts/build-mls.sh" + - ".github/workflows/native-artifacts.yml" + +concurrency: + group: native-artifacts-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write # commit refreshed manifest + .artifact-key back + +jobs: + ios: + name: chat_mls_coreFFI (iOS) + runs-on: macos-15 + timeout-minutes: 60 + env: + PKG: packages/chat-mls-core + R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + # Push commits back to the PR/main branch, not a detached HEAD. + ref: ${{ github.head_ref || github.ref_name }} + + - name: Compute artifact key + id: key + run: | + KEY="$(bash scripts/artifact-key.sh "$PKG" ios)" + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "asset=chat_mls_coreFFI-ios-$KEY.tar.gz" >> "$GITHUB_OUTPUT" + + - name: Already published? + id: exists + run: | + URL="${R2_PUBLIC_BASE_URL%/}/${{ steps.key.outputs.asset }}" + if curl -fsI "$URL" >/dev/null 2>&1; then + echo "found=true" >> "$GITHUB_OUTPUT" + echo "✓ $URL already published — skipping build" + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Select full Xcode + if: steps.exists.outputs.found != 'true' + run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + + - name: Setup Rust + iOS targets + if: steps.exists.outputs.found != 'true' + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + + - name: Cache Rust build + if: steps.exists.outputs.found != 'true' + uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/chat-mls-core + + - name: Build iOS xcframework + if: steps.exists.outputs.found != 'true' + run: bash "$PKG/scripts/build-mls.sh" ios + + - name: Package + if: steps.exists.outputs.found != 'true' + run: | + tar czf "${{ steps.key.outputs.asset }}" \ + -C "$PKG/ios" chat_mls_coreFFI.xcframework + + - name: Upload to R2 + if: steps.exists.outputs.found != 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ vars.R2_BUCKET }} + run: | + aws s3 cp "${{ steps.key.outputs.asset }}" \ + "s3://${R2_BUCKET}/${{ steps.key.outputs.asset }}" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --only-show-errors + echo "→ uploaded ${{ steps.key.outputs.asset }}" + + # The build refreshes ios/*.sha256 + ios/Sources/ and we stamp the key. + # Committing them (only when changed) keeps the consumer's verify gate in + # lockstep with the asset just published. + - name: Commit refreshed manifest + key + if: steps.exists.outputs.found != 'true' && github.event_name != 'pull_request' || (steps.exists.outputs.found != 'true' && github.event.pull_request.head.repo.full_name == github.repository) + run: | + echo "${{ steps.key.outputs.key }}" > "$PKG/ios/.artifact-key" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "$PKG/ios/chat_mls_coreFFI.xcframework.sha256" \ + "$PKG/ios/Sources.sha256" \ + "$PKG/ios/Sources" \ + "$PKG/ios/.artifact-key" + if git diff --cached --quiet; then + echo "nothing to commit" + else + git commit -m "chore(native): publish chat_mls_coreFFI ios (${{ steps.key.outputs.key }})" + git push + fi diff --git a/apps/mobile/.maestro/launch-smoke.yaml b/apps/mobile/.maestro/launch-smoke.yaml new file mode 100644 index 0000000..80bc91a --- /dev/null +++ b/apps/mobile/.maestro/launch-smoke.yaml @@ -0,0 +1,50 @@ +# Phase-0 launch smoke — proves the whole E2E harness end to end WITHOUT a chat. +# +# What it asserts: the built dev-client boots, the auth gate renders, a seeded +# credential sign-in reaches the backend (Better Auth over the API), and the +# app lands on the conversation list. It deliberately stops at the EMPTY list — +# a fresh CI backend has zero chats (the Prisma seed creates accounts/profiles +# only; E2EE chats are provisioned on-device at create-time and can't be +# seeded). Asserting a chat/send/react is Phase 1, which needs an on-device +# chat-establishment flow first (see .maestro/README.md). +# +# So a green run here means: EAS build OK → sim boot OK → api-server + chat-ws +# reachable on localhost → auth OK → chat-list route mounts. That covers the +# large majority of "did we break the app/backend contract" breakage. +# +# Preconditions: app installed on a booted sim; api-server (:3001) + chat-ws +# (:8787) up on the runner; DB seeded with alice (packages/database/prisma/ +# seed.ts, SEED_PASSWORD). Set MAESTRO_APP_ID (e.g. io.sessions.app). +appId: ${MAESTRO_APP_ID} +--- +- launchApp + +# Cold-start sign-in. Mirrors chat-smoke.yaml: launchApp relaunches the process +# and the Better Auth session does not rehydrate, so we land on the auth gate. +# Gated on "Login" being visible — a no-op if a session ever does persist. +# Creds come from the DB seed (SEED_PASSWORD = "password123"). +- runFlow: + when: + visible: "Login" + commands: + - tapOn: "Email Address" + - inputText: "alice@example.com" + - tapOn: "Password" + - inputText: "password123" + # Password field has enterKeyHint="go" + onSubmitEditing → submit via the + # keyboard return key (same rationale as chat-smoke: avoids hideKeyboard + # and a Login-button tap the keyboard can overlap). + - pressKey: Enter + +# Landed on the conversation list. Anchor on the empty-state headline the +# chat-list renders when alice has no chats ("No chats yet", apps/mobile/app/ +# (tabs)/index.tsx). This is the Phase-0 success signal: auth cleared and the +# authed route mounted against a live backend. +- extendedWaitUntil: + visible: "No chats yet" + timeout: 20000 + +# Belt-and-braces: the sticky New Chat action bar is always present on the list +# screen. Asserting it too guards against a future empty-state copy change +# silently passing on a half-rendered screen. +- assertVisible: "New Chat" diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 9a300ca..1ec53a1 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -20,7 +20,8 @@ "com.apple.security.application-groups": ["group.io.sessions.shared"] }, "infoPlist": { - "UIBackgroundModes": ["remote-notification"] + "UIBackgroundModes": ["remote-notification"], + "ITSAppUsesNonExemptEncryption": false } }, "android": { @@ -56,9 +57,16 @@ }, "extra": { "_complianceTodos": [ + "Before first App Store / Play public submission:", + "1. iOS: set ios.config.usesNonExemptEncryption = true. SQLCipher + libsodium → ECCN 5D002 (mass-market via License Exception ENC). US entity → file annual BIS self-classification (CSV to crypt-supp8@bis.doc.gov by Feb 1). Non-US entity → check national regime (UK ECJU / EU dual-use), usually no filing.", "Before first App Store / Play public submission:", "1. iOS: set ios.config.usesNonExemptEncryption = true. SQLCipher + libsodium → ECCN 5D002 (mass-market via License Exception ENC). US entity → file annual BIS self-classification (CSV to crypt-supp8@bis.doc.gov by Feb 1). Non-US entity → check national regime (UK ECJU / EU dual-use), usually no filing." - ] - } + ], + "router": {}, + "eas": { + "projectId": "88084928-70f3-4f8c-987f-e5bbee6779cf" + } + }, + "owner": "lxm7" } } diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 612fae7..35d4112 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -127,7 +127,7 @@ export default function ChatListScreen() { > @@ -158,7 +158,7 @@ export default function ChatListScreen() { {/* Section title */}