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>
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 athttps://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 athttp://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-appstoreon unraid, port3080internally - 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
.envon the server):ADMIN_PASSWORD,API_TOKEN,SESSION_SECRET,BASE_URLBUILDER_URL=http://10.3.3.192:3090— LAN address of the Mac mini builderBUILDER_SHARED_SECRET— must matchbuilder/.envon the Mac mini
Builder (Mac mini)
- URL: http://Treys-Mac-mini.local:3090 (LAN-only, no SSL, no public DNS)
- Native Node (not Docker —
xcodebuildcan't run in a container on macOS) - App code:
/Users/m4mini/AppStoreBuilder/app/(copied frombuilder/subtree viabuilder/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 bysrc/server.js):ADMIN_PASSWORD,SESSION_SECRET,DATA_DIR,PORT
- ASC API keys live in the
asc_keystable (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"..p8files stored at$DATA_DIR/asc/<key_id>.p8(0600). At build time, the worker readsDEVELOPMENT_TEAMfromxcodebuild -showBuildSettingsand 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/*bridgesrc/db.js— SQLite schema (apps, builds, devices)src/ipa-parser.js— Unzips IPA, extractsInfo.plistand app iconsrc/manifest.js— Generates the OTA manifest plist iOS fetchessrc/mobileconfig.js— Generates the.mobileconfigProfile Service payload and parses callback plistssrc/auth.js— Session middleware (web UI) + token middleware (API)views/— Login, app listing, upload, enroll pagespublic/— CSS + client-side JS
Storefront auth
- Session cookies for browser users (
ADMIN_PASSWORD) X-Api-Tokenheader 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 workerbuilder/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— Wrapsfastlane sigh, caches.mobileprovisionfiles, auto-installs into~/Library/MobileDevice/Provisioning Profiles/builder/src/build-worker.js— In-process build queue: preparing → signing → archiving → exporting → uploading → succeededbuilder/src/build-routes.js—/api/build/upload,/api/build/git,/api/builds,/api/builds/:id/logs(SSE)builder/fastlane/Fastfile— Singlegenerate_adhoclane using the ASC API keybuilder/bin/deploy.sh— Copies source to/Users/m4mini/AppStoreBuilder/app/and kickstarts launchdbuilder/views/— Login, builds, build, devices, settings pagesbuilder/public/— CSS (copied from the storefront for visual continuity) + client JS
How a build works
- User posts source (.zip/.tar.gz or git URL) to
/api/build/uploador/api/build/git. Server creates abuild_jobsrow withstatus=pending, extracts/clones the source intodata/source/<job-id>/, and kicks the worker. - preparing: worker finds
.xcodeproj/.xcworkspace, picks a scheme, runsxcodebuild -showBuildSettings -jsonto extract every target'sPRODUCT_BUNDLE_IDENTIFIERand theDEVELOPMENT_TEAM. - signing: for each bundle ID,
profile-manager.getProfile()ensures a fresh ad-hoc profile exists — serving from cache if possible, runningfastlane sighif stale. Each profile is installed into~/Library/MobileDevice/Provisioning Profiles/soxcodebuildfinds it. - archiving:
xcodebuild archivewithCODE_SIGN_STYLE=Manual, the detected team ID,-allowProvisioningUpdates. xcodebuild matches bundle IDs to the pre-installed profiles automatically. - exporting: generate
ExportOptions.plistwithmethod=ad-hocand the fullprovisioningProfilesmap, thenxcodebuild -exportArchive. - uploading: the produced
.ipais POSTed tohttps://appstore.treytartt.com/api/uploadusing the existingAPI_TOKEN. The storefront's existing parser + DB insert + manifest generation run unchanged. - 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
- Tester opens
https://appstore.treytartt.com/enrollon their iPhone. - 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. - 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. - 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. - The unraid server forwards
{udid, name, model}to the Mac mini athttp://10.3.3.192:3090/api/devices/from-enrollmentwithAuthorization: Bearer <BUILDER_SHARED_SECRET>. - The builder's
/api/devices/from-enrollmentendpoint:- Upserts into the local
devicestable - 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
- Upserts into the local
- 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/uploadstill 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 whydeploy.shcopies to/Users/m4mini/AppStoreBuilder/app/.- Docker compose env vars are substituted at compose-time from the host
.env, not read from.envat runtime inside the container. When adding a new env var to the storefront, update BOTH.envon the server ANDdocker-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/.envon 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)