# FTStore Admin — project guide for AI agents FTStore Admin is the [`web/`](../web) dashboard as an iOS app: the operator app for the FTStore Admin server, behind the same login as the web UI. > **Monorepo.** Read the root [CLAUDE.md](../CLAUDE.md) first — it owns the tag namespace > (`admin-v*`) and the rule that this directory never carries the upstream > brand. This app was split out of [`manager/`](../manager) (where it was a hidden > second tab); the git history of `App/*.swift` runs back through > `manager/FTStore/Admin/` — it was `Console/*.swift` in between, so a > `git log --follow` across that stretch needs `--find-renames`. ## Brand / identity - App name `FTStore Admin` · bundle id **`app.ftstore.admin`** · target/scheme/product `FTStoreAdmin`. - The *server* in `web/` is also called FTStore Admin. In prose, say "the FTStore Admin server" for `web/` and "the FTStore Admin app" for this one when both are in play. ## The rules that hold it together - **Nothing secret ships in the app.** The only baked-in value is the base URL. Never add a token, key or privileged path — the whole point is that a decrypted IPA is a login form and nothing else. - **No operator surface leaks back.** This app exists so `ios/` and `manager/` never need one. Don't add a dashboard feature to either of them, not even behind a hidden gate; that gate is what was removed here. - **No Swift packages.** The target links system frameworks only. If something needs a helper from `NimbleKit` or the FTStore engine, inline it in `Support/` with a note on where it came from (that is what `AdminUIKit.swift` and `AnyCodable.swift` are). Adding a package dependency is a decision, not a convenience. - **`AdminSession` is the single door.** Every screen calls `session.get/send/act`; that is the one place that attaches the token and the one place that reacts to a 401 (wipe the local session, drop back to the login form). Don't call `AdminAPI` directly from a view. - **The token is Keychain-only** (`AdminKeychain`, service `app.ftstore.admin`, `AfterFirstUnlockThisDeviceOnly`) and the app's `URLSession` is ephemeral with `httpShouldSetCookies = false`, so it is never written to the container. The password is used for one login request and dropped. - **Server-side auth is the real gate.** `withAuth()`/`withAdmin()` on every non-public route is what protects the data; hiding the Users link from non-admins is presentation. If you add a `web/` route this app calls, wrap it — middleware only checks that a cookie *exists*, not that it is valid. - `AdminModels.swift` mirrors the handlers' responses and holds every request body in `AdminRequest`; keep the two in step when a route changes shape. - Two endpoints don't take JSON — the chunked IPA upload and the tweak icon. They go through `AdminAPI.upload` (multipart); the settings backup goes through `AdminAPI.rawData`, because it is a document that has to round-trip byte-for-byte rather than a model to decode and re-encode. - **English and German, chosen by iOS.** `Resources/Localizable.xcstrings` holds both, keyed by the English source string, and `de` is in the project's `knownRegions`. A literal passed to `Text`/`Button`/`Label`/`Section`/`.navigationTitle`/`.alert` is a `LocalizedStringKey` and looks itself up; a `String` built before it reaches a view does not, so that is what `String.localized(…)` in `Support/AdminLocalization.swift` is for. The shared components in `AdminComponents.swift` localize their own `String` labels, which is why call sites keep passing plain English. A missing German entry renders the English key. Never translate text the server sent. - **The `.ipa` file importer needs the type declared.** `Info.plist` imports `app.ftstore.ipa` (the identifier `ios/` exports) because `UTType(filenameExtension:)` answers with a dynamic `dyn.…` type when nothing on the device has claimed the extension, and the document browser then greys out every IPA. `UTType.ipaImportTypes` widens the picker further (`.zip`, `.data`) for files tagged some other way, and `AdminUploadView._pick` is what enforces `.ipa`. - The IPA upload must keep posting **8 MiB chunks in order** (`UPLOAD_CHUNK_SIZE` in `web/src/lib/pipeline/upload-staging.ts`) with a lowercase-UUID `uploadId` — the server validates that id against a strict pattern before it becomes a file path. Chunk reads run on a detached task so an 8 MiB read can't stutter the progress bar. - A Library rename targets a *variant group*, not a build: send `groupKey` + the item's own `renameScope` to `/api/library/apply`, never an edit to the entry row. - Sheets get `.environmentObject(session)` explicitly — a missing `@EnvironmentObject` is a crash, not a nil. - The User-Agent (`FTStore-Admin/…`) deliberately does **not** match `web/`'s `APP_UA` regex. FTStore Admin only calls session-authenticated routes, so it never needs the feed's UA gate — and keeping it outside means nobody widens that gate to make this app work. ## Where things live - `FTStoreAdmin/FTStoreAdminApp.swift` — the `@main`; owns the session and the re-lock on backgrounding. - `FTStoreAdmin/App/*` — transport, session, models, one view per dashboard screen. Named for what it is against `Support/`: this app's own code, not helpers inlined from elsewhere. It was `Console/*` until the operator app stopped being called a console. - `FTStoreAdmin/Support/*` — base URL/User-Agent, brand colour, the inlined helpers. - `FTStoreAdmin.xcconfig` (`FTSTORE_PRODUCT_BUNDLE_IDENTIFIER`, `FTSTORE_ADMIN_BASE_URL`) → injected into `Info.plist` (`FTStoreAdminBaseURL`). **Never commit a real credential.** - `app-repo.json` — the AltStore source template the dev workflow rewrites with `jq`. Its `version`/`downloadURL` are placeholders for a stable `admin-v*` release; the dev build overwrites every one of those fields. ## Distribution Same shape as the app and the Manager: `admin-dev-repo.yml` builds every push touching `admin/` and publishes the IPA plus a generated AltStore source (`admin-devrepo.json`, from `admin/app-repo.json`) to the rolling **`admin-dev`** prerelease. `admin-release.yml` cuts a draft release from an `admin-v*` tag when a stable one is wanted. The repo is private, so that source is reachable only by someone who already has access — which is the point. There is still **no `web/` proxy and no in-app updater** for this app: it talks to the session-gated `/api/*` routes, never to `/api/ftstore-*`. ## Build / validate - macOS + Xcode only. `make` → `packages/FTStore-Admin.ipa`. - Static checks (CI/Linux ok): `plistlib` parse of `FTStoreAdmin/Resources/Info.plist`, and **grep this directory for `ghp_` / any credential — there must be none.** ## GPL-3.0 Keep the root `LICENSE` and the attributions intact; keep source public.