- Indentation: 2 spaces
- Semicolons: Required
- Quotes: Single quotes for strings
- Naming: PascalCase for types/interfaces, camelCase for variables/functions
backend/— Node.js/Express server (TypeScript, ESM)frontend/— React SPA (TypeScript, Vite, Tailwind CSS)e2e/— Playwright E2E tests (pnpm)references/ApplePackage/— Swift reference implementation (source of truth)- Multi-stage Docker build (single container serves both)
The server is a blind TCP proxy. It NEVER sees Apple credentials.
┌─ Browser (Client) ─────────────────────────────────┐
│ Credentials (IndexedDB): email, password, cookies, │
│ passwordToken, DSID, deviceIdentifier, pod │
│ │
│ Apple Protocol (libcurl.js WASM + Mbed TLS 1.3): │
│ 1. Bag fetch → backend proxy → resolve auth URL │
│ (fallback to default auth endpoint if missing) │
│ 2. Authenticate → get token, cookies, pod │
│ 3. Purchase → acquire license │
│ 4. Download info → get CDN URL + SINFs + metadata │
│ 5. Version listing/lookup │
│ │
│ TLS 1.3 encrypted via Wisp protocol over WebSocket │
└──────────────────────┬───────────────────────────────┘
│ Wisp-multiplexed TCP (server cannot read)
┌─ Server (Wisp Proxy) ┴──────────────────────────────┐
│ Wisp server (@mercuryworkshop/wisp-js) on /wisp/ │
│ → multiplexed TCP relay (blind tunnel, no decrypt) │
│ │
│ Bag proxy: GET /api/bag?guid=<id> │
│ - Fetches init.itunes.apple.com/bag.xml via HTTPS │
│ - Returns public Apple service URLs (no creds) │
│ │
│ After client obtains download info: │
│ Client POSTs: { downloadURL, sinfs, metadata } │
│ - downloadURL = Apple CDN (public, no auth) │
│ - sinfs = DRM signatures (base64) │
│ - iTunesMetadata = app metadata plist (base64) │
│ │
│ Server downloads IPA from CDN, injects SINFs + │
│ iTunesMetadata, stores compiled IPA, serves via │
│ public install URL (itms-services manifest) │
└──────────────────────────────────────────────────────┘
Key invariant: The server NEVER sees Apple credentials. All Apple TLS terminates at the browser via libcurl.js WASM (Mbed TLS 1.3). The server only receives public CDN URLs and non-secret metadata for IPA compilation. The bag proxy (/api/bag) only returns public Apple service URLs — no credentials pass through it.
The Swift reference at references/ApplePackage/ is the source of truth for Apple protocol behavior:
- Field mappings (iTunes API → Software type) use Swift
CodingKeys - Authentication flow, bag endpoint, pod routing, error codes
- Always consult the reference when making protocol changes
The backend (backend/src/routes/search.ts) maps raw iTunes API fields to our Software type, matching the Swift CodingKeys in references/ApplePackage/Sources/ApplePackage/Models/Software.swift:
| iTunes Field | Software Field |
|---|---|
trackId |
id |
bundleId |
bundleID |
trackName |
name |
artworkUrl512 |
artworkUrl |
currentVersionReleaseDate |
releaseDate |
All other fields (version, price, artistName, sellerName, description, averageUserRating, userRatingCount, screenshotUrls, minimumOsVersion, fileSizeBytes, releaseNotes, formattedPrice, primaryGenreName) keep their original names.
The backend also extracts the results array from the iTunes wrapper { resultCount, results } before sending to the frontend.
Device identifiers are per-account, not global:
- Generated as 12 random hex chars (6 bytes) at account creation via
generateDeviceId() - Editable during login, immutable after authentication
- Stored in IndexedDB on the
Accountobject asdeviceIdentifier - Passed to all Apple protocol calls (auth, purchase, download, version listing)
After authentication, Apple returns a pod header:
- Store API:
p{pod}-buy.itunes.apple.com(default:p25-buy.itunes.apple.com) - Purchase API:
p{pod}-buy.itunes.apple.com(default:buy.itunes.apple.com) - Pod is stored on the Account object and used for all subsequent API calls
- Functions:
storeAPIHost(pod?)andpurchaseAPIHost(pod?)infrontend/src/apple/config.ts
The Wisp server validates target hosts via hostname_whitelist in backend/src/services/wsProxy.ts:
auth.itunes.apple.com— bag-resolved auth endpointbuy.itunes.apple.com— purchase endpointinit.itunes.apple.com— bag endpoint/^p\d+-buy\.itunes\.apple\.com$/— pod-based hosts- Port restricted to
443only - Direct IP targets blocked (
allow_direct_ip = false) - Loopback IP targets blocked (
allow_loopback_ips = false) - Private/reserved resolved IPs allowed (
allow_private_ips = true) for Docker/OrbStack DNS translation while hostname allowlist remains the primary control
The backend proxies the bag endpoint via GET /api/bag?guid=<deviceId> using Node.js native HTTPS. It sends Configurator-compatible request headers (User-Agent, Accept: application/xml). The bag response is public data (Apple service URLs) — no credentials are involved. See backend/src/routes/bag.ts.
- Express +
@mercuryworkshop/wisp-jsfor HTTP and Wisp proxy - ESM modules (
"type": "module"in package.json) tsxfor development,tscfor production build- SINF injector also handles optional
iTunesMetadata.plistinjection at IPA root - Bag proxy for
init.itunes.apple.com
backend/src/utils/route.ts— shared Express route helpers (getIdParam,requireAccountHash,verifyTaskOwnership)backend/src/config.ts— centralized constants (MAX_DOWNLOAD_SIZE,DOWNLOAD_TIMEOUT_MS,BAG_TIMEOUT_MS,BAG_MAX_BYTES,MIN_ACCOUNT_HASH_LENGTH)
- React 19, React Router 7, Zustand for state
- Tailwind CSS 4 for styling
- Vite for build tooling
- IndexedDB for credential storage (via
idb) libcurl.js(WASM) for browser-side TLS 1.3 via Mbed TLS — connects through Wisp protocolappleRequest()infrontend/src/apple/request.tswrapslibcurl.fetchfor all Apple API calls and forces HTTP/1.1 (_libcurl_http_version: 1.1)- Bag endpoint (
frontend/src/apple/bag.ts) uses backend proxy (/api/bag) and falls back tohttps://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticatewhenauthenticateAccountis missing or bag fetch fails - Authentication (
frontend/src/apple/authenticate.ts) resolves bag endpoint, then setsguidvia URL query manipulation to avoid duplicate/malformed query parameters - Plist build/parse (
frontend/src/apple/plist.ts) uses native XML builder and browser-nativeDOMParser - Cookie helper (
frontend/src/apple/cookies.ts) —extractAndMergeCookies(rawHeaders, existingCookies)replaces the repeated extract-and-merge pattern across all Apple protocol files
- Alert —
<Alert type="error|success|warning">for status messages (replaces inline alert divs) - Modal —
<Modal open={bool} onClose={fn} title={string}>for dialog overlays - Spinner — inline SVG loading spinner for buttons
- CountrySelect — optgroup-based country dropdown with "Available Regions" + "All Regions"
- AppIcon — 3 sizes (40/56/80px), rounded corners, letter fallback
- Badge — color-coded status pill
- ProgressBar — gray track, blue fill, percentage label
- icons — shared SVG icon components (
HomeIcon,AccountsIcon,SearchIcon,DownloadsIcon,SettingsIcon,SunIcon,MoonIcon,SystemIcon) used by Sidebar, MobileNav, and MobileHeader
utils/error.ts—getErrorMessage(e, fallback)for standardized catch-block error extractionutils/crypto.ts— AES-GCM encrypt/decrypt for account export/importutils/account.ts—accountHash(),accountStoreCountry(),firstAccountCountry()
- React / library imports (
useState,useNavigate,useTranslation) - Layout components (
PageContainer) - Common components (
AppIcon,Alert,Spinner,Modal,CountrySelect) - Sibling components within the same feature folder (e.g.,
DownloadIteminsideDownload/) - Hooks / stores (
useAccounts,useSettingsStore) - Apple protocol / API modules (
authenticate,purchaseApp,apiPost) - Utilities (
accountHash,getErrorMessage) - Config (
countryCodeMap,storeIdToCountry) - Types (
type Software)
Enforcement: Every PR must verify import ordering. Common mistakes:
- Putting hooks/stores before layout/common components
- Putting config before utilities
- Putting type imports in the middle instead of last
- Early returns to reduce nesting
try/catchfor async operations- Express error middleware for centralized handling
- Type-safe error responses
2034/2042: Token expired — re-authentication requiredcustomerMessage === 'Your password has changed.': Password token invalidaction.urlending intermsPage: Terms acceptance required (throw with URL)
cd backend && npx vitest run # Node environment
cd frontend && npx vitest run # jsdom environment with fake-indexeddbcd e2e && pnpm test # Local (requires Docker on port 8080)
docker compose --profile test run --rm playwright # Docker-based
bash e2e/docker-test.sh # Full: build + test + zero-trust verifyE2E tests import from ./fixtures instead of @playwright/test.
WebSocket proxy tests use location.host to derive URLs dynamically, so they work both locally (localhost:8080) and in Docker (asspp:8080).
Real-account Docker verification (2026-02-22): authentication succeeds through Wisp, and backend logs contain only connection/stream metadata (no Apple credentials, password tokens, or cookies).
E2E tests cover:
- Wisp proxy (accepts /wisp/ WebSocket, rejects non-wisp paths)
- Add account flow (device ID field, randomize button, auth)
- Account detail (device ID, pod display)
- Settings page (no global device ID section)
- Search/lookup by bundle ID (verifies iTunes field mapping)
- Downloads API (iTunesMetadata support, backward compatibility)
Test credentials are stored in environment variables (TEST_EMAIL, TEST_PASSWORD, TEST_DEVICE_ID, TEST_BUNDLE_ID) and must never be committed to the repository.
docker compose up --build -d # Builds and runs on port 8080Single container serves both the Express backend and the Vite-built React SPA. SPA routes are handled by serving index.html for all non-API paths.
The compose.yml includes a playwright service under the test profile:
docker compose --profile test run --rm playwrightThis runs Playwright inside the official mcr.microsoft.com/playwright image, connecting to the app container via Docker internal DNS (http://asspp:8080). The asspp service has a healthcheck so the test container waits until the app is ready.
The e2e/docker-test.sh script automates the full flow: build, test, and verify zero-trust by scanning backend logs for credential leaks.
Who: Developers and power users managing Apple app downloads outside the App Store — sideloading IPAs, managing multiple Apple IDs, tracking licenses. Technical audience, likely running this alongside terminals or Xcode.
Task: Authenticate Apple accounts → search apps → acquire licenses → download/compile IPAs → install.
Feel: A sharp utility. Precise like a package manager, clear like Apple's developer tools. Confident, quiet, functional. Not playful, not corporate.
- Primary accent:
blue-600/blue-700(hover) — trust + system authority, echoes Apple dev tooling - Backgrounds:
gray-50(app),white(cards/surfaces) - Text:
gray-900(primary),gray-600(secondary),gray-400(tertiary) - Borders:
gray-200(default),gray-300(hover) — use sparingly, prefer background tinting for containment - Status badges: Muted tones —
green(completed),blue(downloading),yellow(paused),purple(injecting),red(failed),gray(pending) - Alerts:
red-50/red-700(error),amber-50/amber-700(warning),green-50/green-700(success)
- System font stack (Inter / SF Pro fallback)
- Weight scale:
500(medium, workhorse),600(semibold, page titles and key labels only). Avoid700in body. - Size scale:
xs(12px),sm(14px),base(16px),lg(18px),xl(20px),2xl(24px)
- Base unit:
4px - Consistent vertical rhythm:
space-y-4within sections,space-y-6between sections - Page padding:
px-4 sm:px-6,py-6 - Container:
max-w-5xl(1024px)
- Single elevation: white cards on
gray-50background - No shadows. Borders only where they serve function (form inputs, dividers, interactive boundaries)
- Rounded corners:
rounded-lg(8px) for cards,rounded-md(6px) for inputs/buttons,rounded-fullfor badges - Prefer background tinting (
gray-50→gray-100) over borders for visual containment
- Desktop: fixed sidebar (240px /
w-60) + scrollable main content - Mobile: bottom tab bar with safe-area padding
- Breakpoint:
md:(768px) for sidebar ↔ bottom nav switch - Page structure:
PageContainerwith title + optional action button, then content
- Buttons: Primary (
bg-blue-600 text-white), Secondary (border border-gray-300 text-gray-700), Danger (text-red-600 border-red-300) - Inputs:
rounded-md border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 - Cards: White background,
border border-gray-200 rounded-lg, no shadow - Badge: Color-coded pill (
rounded-full px-2 py-0.5 text-xs font-medium) - ProgressBar: Gray track, blue fill, percentage label
- AppIcon: 3 sizes (40/56/80px), rounded corners, letter fallback
- Nav active state:
bg-blue-50 text-blue-700(sidebar),text-blue-600(mobile)
These rules prevent the codebase from becoming messy after merging PRs. Enforce them on every change.
Problem: transition-colors on static containers (cards, sections, alerts, badges) causes visible color flashing when the page loads in dark mode — the element briefly renders in light colors then transitions to dark.
Rule: Only use transition-colors on interactive elements that change color on user interaction:
- Buttons (hover state)
- Links (hover state)
- Form inputs and selects (focus state)
- Nav items (hover/active state)
Never use transition-colors on:
- Card containers (
bg-white dark:bg-gray-900 rounded-lg border ...) - Section wrappers (
<section>with background) - Alert/warning banners (use the
<Alert>component) - Badge pills
- ProgressBar tracks
- Modal containers
- AppIcon fallback containers
- Empty state placeholder containers
Exception: Layout chrome (Sidebar, MobileNav, MobileHeader, PageContainer) may keep transition-colors duration-200 for smooth theme toggle animation, since these persist across navigations.
All navigation and theme icons live in components/common/icons.tsx. When Sidebar, MobileNav, or MobileHeader need icons, import from there. Never duplicate icon SVG components inline.
Before merging any frontend PR, verify imports follow the convention in every changed file:
1. React / library imports
2. Layout components
3. Common components
4. Sibling components (same feature folder)
5. Hooks / stores
6. Apple protocol / API modules
7. Utilities
8. Config
9. Types (always last)
Empty states (shown when a list has no items) use a consistent pattern:
border-2 border-dashed(not solid border)bg-gray-50 dark:bg-gray-900/30background- No
transition-colors(removed to prevent dark mode flashing) - Centered icon in a white circle, title, description, optional CTA button
Always pair light and dark variants consistently:
- Primary text:
text-gray-900 dark:text-white - Secondary text:
text-gray-600 dark:text-gray-400ortext-gray-500 dark:text-gray-400 - Tertiary text:
text-gray-400 dark:text-gray-500 - Card background:
bg-white dark:bg-gray-900 - Page background:
bg-gray-50 dark:bg-gray-950 - Card border:
border-gray-200 dark:border-gray-800 - Input border:
border-gray-300 dark:border-gray-700
When the same UI pattern appears in 3+ components, extract it to components/common/. Current shared components:
Alert,Modal,Spinner,CountrySelect,AppIcon,Badge,ProgressBar,icons
When adding new common components, update this AGENTS.md file accordingly.