Runbook: Family Manager Apple Go-Live (TestFlight)
Owner: Family Manager (mobile) lead Backup owner: Platform on-call engineer Last validated: 2026-06-14 Validation method: Code-grounded against
family-manager-ios/project.yml,apns_notifier.py,apple_auth.py, andmobile.py; Apple Developer enrollment now ACTIVE Severity trigger: Release-blocking (cannot ship to TestFlight / App Store) Customer impact: None live yet — pre-launch; a misconfig here silently kills push or blocks SIWA sign-in for testers Required access: Apple Developer portal (Org membership, ACTIVE), App Store Connect, thefamily-manager-iosrepo, VPS SSH (ssh curateme@178.105.8.25) Related services:curateme-backend-b2b(mobile facade/api/v1/mobile/*),src/services/notifications/apns_notifier.py,src/services/mobile/apple_auth.pyTime to complete: ~60–90 minutes (first go-live), then ~10 minutes per subsequent TestFlight build
This runbook ships the iOS app Family Manager to TestFlight (then the App Store). The backend is the generic mobile / HITL facade (/api/v1/mobile/*) — there are no family_* names server-side; Family Manager is merely the first client of that facade. This runbook does not revive any B2C/fashion surface.
Enrollment is ACTIVE, so this is now an operational sequence: capture the Team ID, register the App ID + capabilities to match the app’s entitlements, wire DEVELOPMENT_TEAM, create the APNs .p8, set the backend APNS_* / APPLE_SIGN_IN_* env vars, archive, upload, and invite testers (Boris + spouse).
Fixed app identity (already correct — do NOT change):
| Thing | Value | Source |
|---|---|---|
| Bundle ID (app) | ai.curateme.familymanager | project.yml:173 PRODUCT_BUNDLE_IDENTIFIER |
| Bundle ID (widget) | ai.curateme.familymanager.widget | project.yml:208 |
| Bundle ID (share ext) | ai.curateme.familymanager.share | project.yml:285 |
| App Group | group.ai.curateme.familymanager | project.yml:128-129,196-197,262-263 |
| Keychain group | $(AppIdentifierPrefix)ai.curateme.familymanager.shared | project.yml:130-131 |
| Display name | Family Manager | project.yml:135 |
| Marketing / build | 0.1.0 / 1 | project.yml:26-27 |
| Deployment target | iOS 26.0, Swift 6.0 | project.yml:5-6,24-25 |
| Export compliance | ITSAppUsesNonExemptEncryption: false | project.yml:143 |
Two secret homes (NEVER commit a value):
- App / signing secrets →
family-manager-ios/App/Secrets.xcconfig(gitignored:family-manager-ios/.gitignorelistsSecrets.xcconfig,*.p8,*.p12,*.mobileprovision,*.cer). This file does not exist yet — you create it in Step 3. - Backend secrets →
services/backend/.env(local) /~/platform/.env.production(VPS). The APNs.p8body and the SIWAaudland here, never in git.
Prerequisites
Before starting, confirm you have:
- Apple Developer Program — Organization membership, ACTIVE. (Enrollment is done. If you ever re-verify: https://developer.apple.com/account → Membership.)
- App Store Connect access with the Admin or App Manager role.
- The
family-manager-iosrepo cloned at/Users/borisbarash/Curate-Me.ai/family-manager-ios, plusxcodegeninstalled (brew install xcodegen; the.xcodeprojis generated, never committed —.gitignore*.xcodeproj/). - VPS SSH to set backend env:
ssh curateme@178.105.8.25(the.env.productionlives at~/platform/.env.production). - A physical iPhone (push + SIWA cannot be fully exercised on the Simulator) running iOS
26.0+.
Step 1: Capture the Team ID
The Apple Team ID is reused in three places downstream, so capture it once.
- Go to https://developer.apple.com/account → Membership details.
- Copy the Team ID (10 chars, e.g.
A1B2C3D4E5).
This single Team ID feeds:
- iOS signing →
DEVELOPMENT_TEAM(Step 3). - APNs provider-JWT
iss→ backendAPNS_TEAM_ID(Step 4;apns_notifier.py:178). - SIWA does not need the Team ID directly — the SIWA
audis the bundle id (Step 5).
Verification: The Team ID is exactly 10 alphanumeric characters. Record it in your password manager (not in git).
Step 2: Register the App ID + enable capabilities
Apple Developer → Certificates, Identifiers & Profiles → Identifiers → + → App IDs → App. Register an explicit App ID ai.curateme.familymanager (a wildcard * App ID cannot carry Push, App Groups, or SIWA — it must be explicit).
Enable these capabilities on the App ID to match the entitlements the app already declares (App/FamilyManager.entitlements + project.yml properties):
| Capability | Enable? | App entitlement it backs | Grounding |
|---|---|---|---|
| Push Notifications | YES | aps-environment (you ADD this in Step 4 — currently absent) | apns_notifier.py whole file; categories CM_APPROVAL_V1 / CM_BRIEF_V1 |
| Sign in with Apple | YES | com.apple.developer.applesignin (you ADD this in Step 5 — currently absent) | apple_auth.py; route mobile.py:1158 POST /session/apple |
| App Groups | YES | com.apple.security.application-groups → group.ai.curateme.familymanager (ALREADY in App/FamilyManager.entitlements + project.yml:128-129) | widget reads + share-extension keychain group both ride this group |
| Calendars (EventKit) | n/a | NONE — usage-string + runtime auth only; there is no “EventKit” box | usage strings project.yml:148-149 |
The strategy doc (2026-06-08) said App Groups could be deferred because no widget existed. That is stale —
project.ymlnow ships aFamilyManagerWidget(:181) and aFamilyManagerShareExtension(:230) target, both joininggroup.ai.curateme.familymanager. App Groups is required now, and the App Group must be registered (Apple Developer → Identifiers → App Groups → registergroup.ai.curateme.familymanager) and associated with the App ID.
Verification: On the App ID’s edit page, Push Notifications, Sign in with Apple, and App Groups all show enabled, and the App Group identifier reads exactly group.ai.curateme.familymanager.
Step 3: Wire DEVELOPMENT_TEAM via a gitignored Secrets.xcconfig
The repo is set up for automatic signing (project.yml:29 CODE_SIGN_STYLE: Automatic), but DEVELOPMENT_TEAM is still empty (project.yml:28 DEVELOPMENT_TEAM: "") and there is no Secrets.xcconfig. Wire the Team ID without committing it.
- Create
family-manager-ios/App/Secrets.xcconfig(gitignored —.gitignorelistsSecrets.xcconfig):(use the real Team ID from Step 1).DEVELOPMENT_TEAM = A1B2C3D4E5 - Reference it from
project.ymlso xcodegen injects it into the app target. Add aconfigFilesblock to theFamilyManagertarget:(Keep the value in the xcconfig, not intargets: FamilyManager: configFiles: Debug: App/Secrets.xcconfig Release: App/Secrets.xcconfigproject.yml:28— that file is committed.) - Regenerate and confirm the project resolves the team:
cd /Users/borisbarash/Curate-Me.ai/family-manager-ios xcodegen generate
Verification: xcodegen generate succeeds; opening the generated FamilyManager.xcodeproj → FamilyManager target → Signing & Capabilities shows the team resolved (no “Signing requires a development team” error) and Push / Sign in with Apple / App Groups capabilities listed.
Step 4: Create the APNs .p8 and set the backend APNS_* env vars
Create the APNs Auth Key (one .p8 works for every app under the team, sandbox + production):
- Apple Developer → Keys → + → name it (e.g. Curate-Me APNs) → check Apple Push Notifications service (APNs) → Continue → Register.
- Download the
.p8ONCE (Apple only allows a single download). Record the Key ID (10 chars, e.g.ABC123DEFG).
The backend already mints the ES256 provider JWT ({alg:ES256, kid:APNS_KEY_ID}, {iss:APNS_TEAM_ID}) from these — see apns_notifier.py:397.
Set the exact backend env vars (read by ApnsNotifier.__init__, apns_notifier.py:177-200; documented in services/backend/.env.example:973-989). Canonical names below; legacy fallbacks in parentheses are accepted but use the canonical:
| Env var | Value | Code |
|---|---|---|
APNS_KEY_P8 (legacy APNS_AUTH_KEY) | The PEM body of the .p8 (-----BEGIN PRIVATE KEY----- … -----END PRIVATE KEY-----) OR a filesystem path to the .p8. | apns_notifier.py:183-184 |
APNS_KEY_ID | The 10-char Key ID from the key you just created (JWT kid). | apns_notifier.py:177 |
APNS_TEAM_ID | The Team ID from Step 1 (JWT iss). | apns_notifier.py:178 |
APNS_TOPIC (legacy APNS_BUNDLE_ID) | ai.curateme.familymanager (the apns-topic HTTP/2 header). | apns_notifier.py:189-190 |
APNS_USE_SANDBOX (legacy APNS_ENV=sandbox) | false → production host api.push.apple.com; true → sandbox api.sandbox.push.apple.com. | apns_notifier.py:197-200, hosts :61-62 |
Which host for TestFlight (load-bearing): the APNs environment is decided by the BUILD, not the channel. A TestFlight build is a distribution (release) build → it registers a PRODUCTION APNs token. So:
- TestFlight + App Store →
APNS_USE_SANDBOX=false(production host) on~/platform/.env.production. - Local Xcode debug build on a device →
APNS_USE_SANDBOX=trueon your local.envonly. - Do not set sandbox on
.env.production: a production token pushed via the sandbox host returnsBadDeviceToken, and the adapter prunes that token (apns_notifier.py:89), silently killing push for that device.
Set them on the VPS (the .p8 body, never a committed file):
ssh curateme@178.105.8.25
# edit ~/platform/.env.production — add:
# APNS_KEY_P8=<PEM body of the .p8> (or a path to a service-readable .p8 on the VPS)
# APNS_KEY_ID=<10-char Key ID>
# APNS_TEAM_ID=<10-char Team ID>
# APNS_TOPIC=ai.curateme.familymanager
# APNS_USE_SANDBOX=falseThen redeploy the backend so the new env is loaded:
./scripts/deploy-to-vps.sh --backendAdd the push entitlement to the app. App/FamilyManager.entitlements currently has App Groups + keychain groups but no aps-environment. Add it:
<key>aps-environment</key>
<string>development</string>(With automatic signing, Xcode promotes this to production for the distribution/TestFlight build; the entry just must be present.) Then xcodegen generate again.
Verification (no live device needed): ApnsNotifier.configured is True only when KEY_ID + TEAM_ID + KEY_P8 + bundle id are all set (apns_notifier.py:211-213). Confirm via the adapter status (apns_notifier.py:627-633, returns status: connected + environment + bundle id) — or inspect the live container:
ssh curateme@178.105.8.25
docker exec curateme-backend-b2b python -c "from src.services.notifications.apns_notifier import ApnsNotifier; n=ApnsNotifier(); print('configured', n.configured)"Expect configured True. If False, the adapter returns not_configured on every send (no crash) — recheck the five env vars.
Step 5: Enable Sign in with Apple + set the SIWA audience
What the backend verifies (apple_auth.verify_apple_identity_token): RS256 signature against Apple JWKS https://appleid.apple.com/auth/keys; iss == https://appleid.apple.com (apple_auth.py:58); aud == the configured client/bundle id; exp fresh; the sha256-hashed nonce matches. The expected aud resolves first-non-empty-wins: APPLE_SIGN_IN_CLIENT_ID → APPLE_SIGN_IN_AUD → APNS_TOPIC → APNS_BUNDLE_ID, falling back to the hard default ai.curateme.familymanager (apple_auth.py:64,80-83).
Because a native on-device sign-in mints a token whose aud IS the bundle id ai.curateme.familymanager — exactly the default — you do NOT strictly need a separate Services ID or any SIWA secret for the identity-token path. Set the audience explicitly anyway for clarity.
- [Apple portal] On the App ID
ai.curateme.familymanager, confirm Sign in with Apple is enabled (Step 2). For a single-app setup, “Enable as a primary App ID”. A Services ID is only needed for a web/OAuth companion — skip it for the native app. A SIWA.p8is only needed for server-side token revocation (a future feature), not for the identity-token path the backend uses today (apple_auth.pyfetches only the public JWKS). - [Repo] Add the SIWA entitlement to
App/FamilyManager.entitlements:Without it,<key>com.apple.developer.applesignin</key> <array><string>Default</string></array>ASAuthorizationAppleIDProviderfails at runtime and the app can never produce an identity token. Thenxcodegen generate. - [Env] Set the SIWA audience on the VPS
~/platform/.env.production(read byapple_auth.py:80; documented.env.example:997-1005):(Optional — it defaults to the bundle id viaAPPLE_SIGN_IN_CLIENT_ID=ai.curateme.familymanagerAPNS_TOPICanyway.) Redeploy if changed:./scripts/deploy-to-vps.sh --backend.
Verification: A real POST /api/v1/mobile/session/apple from the app (SIWA on-device) returns a cm_sk_* member key with HTTP 200. A 401 invalid_token with an audience mismatch means aud ≠ the bundle id — recheck APPLE_SIGN_IN_CLIENT_ID.
Step 6: Confirm the account-deletion teardown (Apple 5.1.1(v))
Apple requires functional in-app account deletion. The strategy doc flagged this as a TODO blocker; it is now implemented — DELETE /session (mobile.py:1755 delete_account) revokes the caller’s cm_sk_* key FIRST (_revoke_member_key, mobile.py:1472), then deletes this member’s org-scoped mobile data (_delete_many_scoped, mobile.py:1432, always org+member scoped), and is idempotent (a second call 401s once the key is gone). No action needed beyond confirming it works.
Verification: From a TestFlight build, exercise the in-app “Delete account” path; the call returns HTTP 204 and a subsequent authenticated request returns 401 (the key is revoked). Confirm the entry point exists:
ssh curateme@178.105.8.25
docker exec curateme-backend-b2b grep -n 'delete(\"/session\"' /app/src/api/routes/mobile.pyExpect @router.delete("/session", status_code=204).
Step 7: Archive, upload, and invite TestFlight testers
- Create the App Store Connect record — https://appstoreconnect.apple.com → Apps → + → New App: Platform iOS, Name Family Manager, Bundle ID
ai.curateme.familymanager(must already exist from Step 2), SKUfamily-manager-ios. - Archive + upload:
The build’s
cd /Users/borisbarash/Curate-Me.ai/family-manager-ios xcodegen generate # open FamilyManager.xcodeproj → scheme "FamilyManager" → Product → Archive # → Organizer → Distribute App → App Store Connect → UploadCFBundleShortVersionString(0.1.0) /CFBundleVersion(1) come fromproject.yml:26-27; bumpCURRENT_PROJECT_VERSIONfor each subsequent upload. BecauseITSAppUsesNonExemptEncryption: falseis set (project.yml:143), the per-upload export-compliance prompt is auto-cleared. - TestFlight internal testing (no Beta App Review, builds live within minutes): App Store Connect → TestFlight → Internal Testing → add Boris and spouse by Apple ID.
Verification: The build shows Ready to Test in TestFlight; Boris + spouse receive the invite and can install. Run the full loop on a real device: SIWA sign-in → capture → production APNs push (CM_APPROVAL_V1) → in-app Approve → EventKit calendar write → receipt.
Pre-submission checklist
Repo / env:
-
App/Secrets.xcconfighas the realDEVELOPMENT_TEAM, referenced viaconfigFilesinproject.yml;xcodegen generatesigns cleanly (Step 3). -
App/FamilyManager.entitlementsnow also carriesaps-environment(Step 4) andcom.apple.developer.applesignin(Step 5) — App Groups + keychain groups were already present. -
~/platform/.env.productionhasAPNS_KEY_P8,APNS_KEY_ID,APNS_TEAM_ID,APNS_TOPIC=ai.curateme.familymanager,APNS_USE_SANDBOX=false; the.p8is NOT committed. -
~/platform/.env.productionhasAPPLE_SIGN_IN_CLIENT_ID=ai.curateme.familymanager(or relies on the bundle-id default). - Backend
docker exec curateme-backend-b2breports APNsconfigured True;POST /session/applereturns acm_sk_*(200). -
DELETE /sessionteardown verified (Step 6) — Apple 5.1.1(v) satisfied.
Apple portal:
- Team ID recorded (Step 1).
- Explicit App ID
ai.curateme.familymanagerwith Push + Sign in with Apple + App Groups (group.ai.curateme.familymanager) enabled (Step 2). - APNs
.p8created, Key ID recorded, downloaded once, stored in.env.production(Step 4). - App Store Connect record created; build archived + uploaded (Step 7).
- Privacy Nutrition Labels submitted honestly: no Core Location (no
CLLocationManagerstarted), no Audio (Speech is on-device; only the transcript text leaves), no Photos (Vision OCR on-device; only extracted text leaves); collected = User ID (Applesub), Device ID (push token), User Content (capture text/proposal), Usage Data (opaque first-party counts). ATT not required (no cross-app tracking, no ad SDKs).
Functional smoke (real device, TestFlight build):
- Push permission prompt appears; device token registers via
POST /api/v1/mobile/devices. - An approval push renders Approve / Edit actions (
CM_APPROVAL_V1). - Tapping Approve without Calendar access deep-links instead of silently failing.
- A
verified:falsereceipt surfaces a loud FAILED, never a silent success. - A capture with
third_party_ai=falsereturns403 consent_required;PUT /session/consentunblocks it.
Rollback / If it goes wrong
| Symptom | Likely cause | Fix |
|---|---|---|
| Push never arrives on a TestFlight build | APNS_USE_SANDBOX=true on .env.production (prod token rejected as BadDeviceToken → token pruned, apns_notifier.py:89) | Set APNS_USE_SANDBOX=false, ./scripts/deploy-to-vps.sh --backend; the device re-registers on next launch |
Adapter reports not_configured / configured False | One of APNS_KEY_P8 / APNS_KEY_ID / APNS_TEAM_ID / APNS_TOPIC missing | Re-check all five env vars (apns_notifier.py:211-213); redeploy backend |
POST /session/apple returns 401 audience mismatch | Token aud ≠ bundle id | Set APPLE_SIGN_IN_CLIENT_ID=ai.curateme.familymanager (or rely on APNS_TOPIC default); confirm the app signs in on-device (not via a Services ID) |
xcodegen generate won’t sign | Secrets.xcconfig missing or not referenced via configFiles | Recreate the xcconfig with the real DEVELOPMENT_TEAM; add the configFiles block (Step 3) |
| App Group / keychain read goes dark in the widget or share extension | The App Group wasn’t registered on the App ID, or the entitlements lists drifted | Register group.ai.curateme.familymanager (Step 2); keep the application-groups + keychain-access-groups lists identical across app, widget, and share-extension targets (project.yml:128-131,196-197,262-265) |
Backing out a bad backend env change: .env.production is the only authority for the APNs host and SIWA audience. Restore the previous values on the VPS and re-run ./scripts/deploy-to-vps.sh --backend. No iOS rebuild is needed for an env-only rollback. To pull a TestFlight build, expire it in App Store Connect → TestFlight (testers keep installed copies until you expire the build).
Related
- Family Manager Apple Go-Live strategy doc — the code-cited planning source for this runbook
- Rotate Claude Code OAT — VPS env + redeploy pattern
- Agent Identity Provisioning — sibling M365/identity provisioning runbook
- Deployment Procedure —
./scripts/deploy-to-vps.shreference