Files
AppStore/CLAUDE.md
Trey T 491f3a22ba Builder v2: local project browser + multi-team ASC keys
Rewrites the builder console to browse local Xcode projects instead of
accepting source uploads or git URLs. Replaces the devices page with a
profiles page that manages ad-hoc provisioning profiles and lists
registered bundle IDs per team.

Adds multi-account support: ASC API keys are now stored in an asc_keys
table keyed by team_id (team_name, key_id, issuer_id, p8_filename). At
build time, the worker reads DEVELOPMENT_TEAM from the Xcode project and
auto-picks the matching key for fastlane sigh + JWT signing. Legacy
single-key settings auto-migrate on first boot.

Fixes storefront IPA parser to handle binary plists produced by Xcode.
Drops the enrollment bridge, device management routes, and direct
ASC API client -- fastlane sigh handles profile lifecycle now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 14:43:16 -05:00

193 lines
13 KiB
Markdown

# iOS App Store
Hybrid iOS distribution system. Two independent services sharing one git repo:
- **`/` (root)** — the public **storefront** on unraid. Hosts IPAs, serves OTA installs, handles device enrollment. Reached by iPhones at `https://appstore.treytartt.com`.
- **`builder/`** — the private **build console** on the Mac mini. Takes source code (archive or git URL), runs xcodebuild + fastlane, pushes finished IPAs to the storefront. Reached by the developer on the LAN at `http://Treys-Mac-mini.local:3090` (`10.3.3.192:3090`).
The split exists because `xcodebuild` needs macOS and the Mac mini is the only mac we have, but unraid is a much better place to store + serve IPAs long-term.
## Live deployment
### Storefront (unraid)
- **URL**: https://appstore.treytartt.com (public, Let's Encrypt via NPM)
- **Container**: `ios-appstore` on unraid, port `3080` internally
- **App code**: `/mnt/user/appdata/ios-appstore/` (Dockerfile, source, compose, `.env`)
- **Data volume**: `/mnt/user/downloads/ios-appstore/` → mounted as `/data` (SQLite DB, IPAs, icons)
- **NPM proxy host #16**: `appstore.treytartt.com``10.3.3.11:3080`, SSL forced
- **Env vars** (in `.env` on the server):
- `ADMIN_PASSWORD`, `API_TOKEN`, `SESSION_SECRET`, `BASE_URL`
- `BUILDER_URL=http://10.3.3.192:3090` — LAN address of the Mac mini builder
- `BUILDER_SHARED_SECRET` — must match `builder/.env` on the Mac mini
### Builder (Mac mini)
- **URL**: http://Treys-Mac-mini.local:3090 (LAN-only, no SSL, no public DNS)
- **Native Node** (not Docker — `xcodebuild` can't run in a container on macOS)
- **App code**: `/Users/m4mini/AppStoreBuilder/app/` (copied from `builder/` subtree via `builder/bin/deploy.sh`)
- **Data**: `/Users/m4mini/AppStoreBuilder/data/` (SQLite + ASC keys + source archives + build artifacts + logs)
- **Process supervision**: launchd — `~/Library/LaunchAgents/com.88oak.appstorebuilder.plist` (KeepAlive, RunAtLoad)
- **Env vars** (in `builder/.env`, loaded non-destructively by `src/server.js`):
- `ADMIN_PASSWORD`, `SESSION_SECRET`, `DATA_DIR`, `PORT`
- **ASC API keys** live in the `asc_keys` table (one row per Apple Developer team), not in env/settings. Columns: `team_id`, `team_name`, `key_id`, `issuer_id`, `p8_filename`. Managed at `/settings` → "Developer Accounts". `.p8` files stored at `$DATA_DIR/asc/<key_id>.p8` (0600). At build time, the worker reads `DEVELOPMENT_TEAM` from `xcodebuild -showBuildSettings` and looks up the matching key.
**Important**: The builder code must NOT live under `~/Desktop/` when running via launchd. macOS TCC blocks launchd-spawned processes from reading Desktop, which causes the Node process to hang on `__getcwd` during startup. That's why we copy to `/Users/m4mini/AppStoreBuilder/app/` via the deploy script instead of pointing launchd directly at the git checkout in `~/Desktop/code/ios-appstore/builder/`.
## Deploy flow
### Storefront (unraid)
```bash
# Sync changes (excluding builder/, data, .env, node_modules)
rsync -avz --exclude node_modules --exclude data --exclude .env --exclude builder \
/Users/m4mini/Desktop/code/ios-appstore/ \
unraid:/mnt/user/appdata/ios-appstore/
# Rebuild + restart
ssh unraid "cd /mnt/user/appdata/ios-appstore && docker compose up -d --build"
# Verify
curl -s https://appstore.treytartt.com/api/health
```
### Builder (Mac mini)
```bash
# Single command: rsync to /Users/m4mini/AppStoreBuilder/app/, kickstart launchd, health check
/Users/m4mini/Desktop/code/ios-appstore/builder/bin/deploy.sh
```
## Architecture overview
```
iPhone ────► https://appstore.treytartt.com ◄──── Developer on LAN
│ │
│ │
▼ ▼
NPM + LE (unraid) http://Treys-Mac-mini.local:3090
│ │
▼ ▼
ios-appstore container AppStoreBuilder (Node + launchd)
(Docker, Node 20 Alpine) ─────────────────────────────────
───────────────────── • Source upload + git clone
• /api/apps browse • xcodebuild archive + export
• /api/upload (IPAs in) • fastlane sigh (ad-hoc profiles)
• /api/manifest OTA • ASC API (devices, profiles)
• /api/download IPA • Build queue + log streaming
• /enroll/ public flow ─────►• /api/devices/from-enrollment
▲ │
│ │
└──────────────────────────────┘
finished IPAs POSTed
to /api/upload via
the existing API_TOKEN
```
## Storefront (root `/src/`)
- `src/server.js` — Express app, all routes, multer upload handling, `/enroll/*` bridge
- `src/db.js` — SQLite schema (apps, builds, devices)
- `src/ipa-parser.js` — Unzips IPA, extracts `Info.plist` and app icon
- `src/manifest.js` — Generates the OTA manifest plist iOS fetches
- `src/mobileconfig.js` — Generates the `.mobileconfig` Profile Service payload and parses callback plists
- `src/auth.js` — Session middleware (web UI) + token middleware (API)
- `views/` — Login, app listing, upload, enroll pages
- `public/` — CSS + client-side JS
### Storefront auth
- **Session cookies** for browser users (`ADMIN_PASSWORD`)
- **`X-Api-Token` header** for CLI/automation (`API_TOKEN`) — this is what the Mac mini uses when POSTing IPAs
- **Public (no auth)**: `/api/manifest/:id`, `/api/download/:id`, `/enroll/*` — iOS fetches these unauthenticated
## Builder (`builder/`)
- `builder/src/server.js` — Express app, session auth, mounts all routes, starts the build worker
- `builder/src/db.js` — SQLite schema (settings, devices, apps, profiles, build_jobs)
- `builder/src/auth.js` — Session (web UI) + shared-secret (enrollment bridge from unraid)
- `builder/src/asc-api.js` — App Store Connect REST client (ES256 JWT, `/v1/devices`, `/v1/profiles`, `/v1/bundleIds`)
- `builder/src/profile-manager.js` — Wraps `fastlane sigh`, caches `.mobileprovision` files, auto-installs into `~/Library/MobileDevice/Provisioning Profiles/`
- `builder/src/build-worker.js` — In-process build queue: preparing → signing → archiving → exporting → uploading → succeeded
- `builder/src/build-routes.js``/api/build/upload`, `/api/build/git`, `/api/builds`, `/api/builds/:id/logs` (SSE)
- `builder/fastlane/Fastfile` — Single `generate_adhoc` lane using the ASC API key
- `builder/bin/deploy.sh` — Copies source to `/Users/m4mini/AppStoreBuilder/app/` and kickstarts launchd
- `builder/views/` — Login, builds, build, devices, settings pages
- `builder/public/` — CSS (copied from the storefront for visual continuity) + client JS
### How a build works
1. User posts source (.zip/.tar.gz or git URL) to `/api/build/upload` or `/api/build/git`. Server creates a `build_jobs` row with `status=pending`, extracts/clones the source into `data/source/<job-id>/`, and kicks the worker.
2. **preparing**: worker finds `.xcodeproj`/`.xcworkspace`, picks a scheme, runs `xcodebuild -showBuildSettings -json` to extract every target's `PRODUCT_BUNDLE_IDENTIFIER` and the `DEVELOPMENT_TEAM`.
3. **signing**: for each bundle ID, `profile-manager.getProfile()` ensures a fresh ad-hoc profile exists — serving from cache if possible, running `fastlane sigh` if stale. Each profile is installed into `~/Library/MobileDevice/Provisioning Profiles/` so `xcodebuild` finds it.
4. **archiving**: `xcodebuild archive` with `CODE_SIGN_STYLE=Manual`, the detected team ID, `-allowProvisioningUpdates`. xcodebuild matches bundle IDs to the pre-installed profiles automatically.
5. **exporting**: generate `ExportOptions.plist` with `method=ad-hoc` and the full `provisioningProfiles` map, then `xcodebuild -exportArchive`.
6. **uploading**: the produced `.ipa` is POSTed to `https://appstore.treytartt.com/api/upload` using the existing `API_TOKEN`. The storefront's existing parser + DB insert + manifest generation run unchanged.
7. **succeeded**: clean up source + archive (keep log + IPA + ExportOptions.plist in `data/build/<job-id>/`).
Profile cache invalidation: whenever a device is added/deleted (manually via UI or via the enrollment bridge), `invalidateProfilesForDeviceChange()` clears `updated_at` on every row in `profiles`, forcing the next build to regenerate via `sigh`.
## Enrollment flow
1. Tester opens `https://appstore.treytartt.com/enroll` on their iPhone.
2. Tap "Install Profile" → downloads `/enroll/profile.mobileconfig` (Profile Service payload pointing back at `/enroll/callback`). iOS shows "Not Signed" — acceptable for an internal store, just tap Install.
3. iOS installs the profile, collects device attributes (UDID, PRODUCT, VERSION, DEVICE_NAME, SERIAL), wraps them in a CMS-signed plist, and POSTs to `/enroll/callback`.
4. The unraid storefront doesn't validate the CMS signature — it just scans the raw body for the inner `<?xml … </plist>` block and extracts UDID/name/model.
5. The unraid server forwards `{udid, name, model}` to the Mac mini at `http://10.3.3.192:3090/api/devices/from-enrollment` with `Authorization: Bearer <BUILDER_SHARED_SECRET>`.
6. The builder's `/api/devices/from-enrollment` endpoint:
- Upserts into the local `devices` table
- Calls `asc.registerDevice()` to register with Apple via the App Store Connect API
- Clears the profile cache so the next build picks up the new device
7. iOS is redirected (303) to `/enroll/success`.
The shared secret lives in both `.env` files and must match. Rotate by updating both sides.
## Endpoints quick reference
### Storefront (public URL)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/` | session | App listing UI |
| GET | `/upload` | session | Upload page |
| POST | `/api/upload` | token | Upload IPA (used by Mac mini builder) |
| GET | `/api/apps` | token | List apps |
| GET | `/api/manifest/:id` | public | iOS install manifest |
| GET | `/api/download/:id` | public | Download IPA |
| GET | `/enroll` | public | Enrollment landing page |
| GET | `/enroll/profile.mobileconfig` | public | Profile Service payload |
| POST | `/enroll/callback` | public (raw body) | iOS UDID posts here |
| GET | `/enroll/success` | public | Post-enrollment page |
### Builder (LAN URL)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/` | session | Build list UI |
| GET | `/build` | session | New build form |
| GET | `/devices` | session | Device management UI |
| GET | `/settings` | session | ASC + unraid settings |
| POST | `/api/build/upload` | session | Upload source archive |
| POST | `/api/build/git` | session | Clone git repo |
| GET | `/api/builds` | session | List jobs |
| GET | `/api/builds/:id/logs` | session | SSE log stream |
| GET | `/api/devices` | session | List devices |
| POST | `/api/devices` | session | Register (local + ASC) |
| POST | `/api/devices/from-enrollment` | shared secret | Called by unraid enroll bridge |
| GET | `/api/profile/:bundleId` | session | Fetch/generate ad-hoc profile |
| POST | `/api/settings/test-asc` | session | Verify ASC key works |
| POST | `/api/settings/test-unraid` | session | Verify unraid API token works |
## Gotchas
- **Dev-signed IPAs cannot be installed OTA.** The builder always produces ad-hoc builds via fastlane sigh. Manual uploads via `/api/upload` still work, but the uploader is responsible for the signing method.
- **`builder/` code must not live under `~/Desktop/`** when launchd runs it — TCC blocks the process. That's why `deploy.sh` copies to `/Users/m4mini/AppStoreBuilder/app/`.
- **Docker compose env vars are substituted at compose-time** from the host `.env`, not read from `.env` at runtime inside the container. When adding a new env var to the storefront, update BOTH `.env` on the server AND `docker-compose.yml`.
- **The builder's Node version is 25.x**, the storefront's container is Node 20. They're independent; mixing package-lock.json files between the two will not work.
- **Device cache invalidation is eager**: adding or removing any device marks every profile stale. This is fine for a personal setup but would be worth scoping per-app in a larger deployment.
## Local credentials (look these up, don't hardcode)
- Storefront admin password, API token, session secret: `/mnt/user/appdata/ios-appstore/.env` on unraid
- Builder admin password, session secret, shared secret: `/Users/m4mini/Desktop/code/ios-appstore/builder/.env` (dev) and `/Users/m4mini/AppStoreBuilder/app/.env` (deployed — same file, synced via deploy.sh excluding .env so they can diverge)