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

13 KiB

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.com10.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)

# 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)

# 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)