# 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/.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//`, 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//`). 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 `` 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 `. 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)