Skip to Content
RunbooksRunbook: Family Manager Apple Go-Live (TestFlight)

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, and mobile.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, the family-manager-ios repo, 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.py Time 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):

ThingValueSource
Bundle ID (app)ai.curateme.familymanagerproject.yml:173 PRODUCT_BUNDLE_IDENTIFIER
Bundle ID (widget)ai.curateme.familymanager.widgetproject.yml:208
Bundle ID (share ext)ai.curateme.familymanager.shareproject.yml:285
App Groupgroup.ai.curateme.familymanagerproject.yml:128-129,196-197,262-263
Keychain group$(AppIdentifierPrefix)ai.curateme.familymanager.sharedproject.yml:130-131
Display nameFamily Managerproject.yml:135
Marketing / build0.1.0 / 1project.yml:26-27
Deployment targetiOS 26.0, Swift 6.0project.yml:5-6,24-25
Export complianceITSAppUsesNonExemptEncryption: falseproject.yml:143

Two secret homes (NEVER commit a value):

  • App / signing secretsfamily-manager-ios/App/Secrets.xcconfig (gitignored: family-manager-ios/.gitignore lists Secrets.xcconfig, *.p8, *.p12, *.mobileprovision, *.cer). This file does not exist yet — you create it in Step 3.
  • Backend secretsservices/backend/.env (local) / ~/platform/.env.production (VPS). The APNs .p8 body and the SIWA aud land 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-ios repo cloned at /Users/borisbarash/Curate-Me.ai/family-manager-ios, plus xcodegen installed (brew install xcodegen; the .xcodeproj is generated, never committed — .gitignore *.xcodeproj/).
  • VPS SSH to set backend env: ssh curateme@178.105.8.25 (the .env.production lives 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.

  1. Go to https://developer.apple.com/account Membership details.
  2. Copy the Team ID (10 chars, e.g. A1B2C3D4E5).

This single Team ID feeds:

  1. iOS signing → DEVELOPMENT_TEAM (Step 3).
  2. APNs provider-JWT iss → backend APNS_TEAM_ID (Step 4; apns_notifier.py:178).
  3. SIWA does not need the Team ID directly — the SIWA aud is 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 & ProfilesIdentifiers+App IDsApp. 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):

CapabilityEnable?App entitlement it backsGrounding
Push NotificationsYESaps-environment (you ADD this in Step 4 — currently absent)apns_notifier.py whole file; categories CM_APPROVAL_V1 / CM_BRIEF_V1
Sign in with AppleYEScom.apple.developer.applesignin (you ADD this in Step 5 — currently absent)apple_auth.py; route mobile.py:1158 POST /session/apple
App GroupsYEScom.apple.security.application-groupsgroup.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/aNONE — usage-string + runtime auth only; there is no “EventKit” boxusage strings project.yml:148-149

The strategy doc (2026-06-08) said App Groups could be deferred because no widget existed. That is staleproject.yml now ships a FamilyManagerWidget (:181) and a FamilyManagerShareExtension (:230) target, both joining group.ai.curateme.familymanager. App Groups is required now, and the App Group must be registered (Apple Developer → Identifiers → App Groups → register group.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.

  1. Create family-manager-ios/App/Secrets.xcconfig (gitignored — .gitignore lists Secrets.xcconfig):
    DEVELOPMENT_TEAM = A1B2C3D4E5
    (use the real Team ID from Step 1).
  2. Reference it from project.yml so xcodegen injects it into the app target. Add a configFiles block to the FamilyManager target:
    targets: FamilyManager: configFiles: Debug: App/Secrets.xcconfig Release: App/Secrets.xcconfig
    (Keep the value in the xcconfig, not in project.yml:28 — that file is committed.)
  3. 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.xcodeprojFamilyManager 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):

  1. Apple Developer → Keys+ → name it (e.g. Curate-Me APNs) → check Apple Push Notifications service (APNs) → Continue → Register.
  2. Download the .p8 ONCE (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 varValueCode
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_IDThe 10-char Key ID from the key you just created (JWT kid).apns_notifier.py:177
APNS_TEAM_IDThe 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=true on your local .env only.
  • Do not set sandbox on .env.production: a production token pushed via the sandbox host returns BadDeviceToken, 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=false

Then redeploy the backend so the new env is loaded:

./scripts/deploy-to-vps.sh --backend

Add 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_IDAPPLE_SIGN_IN_AUDAPNS_TOPICAPNS_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.

  1. [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 .p8 is only needed for server-side token revocation (a future feature), not for the identity-token path the backend uses today (apple_auth.py fetches only the public JWKS).
  2. [Repo] Add the SIWA entitlement to App/FamilyManager.entitlements:
    <key>com.apple.developer.applesignin</key> <array><string>Default</string></array>
    Without it, ASAuthorizationAppleIDProvider fails at runtime and the app can never produce an identity token. Then xcodegen generate.
  3. [Env] Set the SIWA audience on the VPS ~/platform/.env.production (read by apple_auth.py:80; documented .env.example:997-1005):
    APPLE_SIGN_IN_CLIENT_ID=ai.curateme.familymanager
    (Optional — it defaults to the bundle id via APNS_TOPIC anyway.) 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 implementedDELETE /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.py

Expect @router.delete("/session", status_code=204).


Step 7: Archive, upload, and invite TestFlight testers

  1. Create the App Store Connect recordhttps://appstoreconnect.apple.com Apps+New App: Platform iOS, Name Family Manager, Bundle ID ai.curateme.familymanager (must already exist from Step 2), SKU family-manager-ios.
  2. Archive + upload:
    cd /Users/borisbarash/Curate-Me.ai/family-manager-ios xcodegen generate # open FamilyManager.xcodeproj → scheme "FamilyManager" → Product → Archive # → Organizer → Distribute App → App Store Connect → Upload
    The build’s CFBundleShortVersionString (0.1.0) / CFBundleVersion (1) come from project.yml:26-27; bump CURRENT_PROJECT_VERSION for each subsequent upload. Because ITSAppUsesNonExemptEncryption: false is set (project.yml:143), the per-upload export-compliance prompt is auto-cleared.
  3. TestFlight internal testing (no Beta App Review, builds live within minutes): App Store Connect → TestFlightInternal 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.xcconfig has the real DEVELOPMENT_TEAM, referenced via configFiles in project.yml; xcodegen generate signs cleanly (Step 3).
  • App/FamilyManager.entitlements now also carries aps-environment (Step 4) and com.apple.developer.applesignin (Step 5) — App Groups + keychain groups were already present.
  • ~/platform/.env.production has APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID, APNS_TOPIC=ai.curateme.familymanager, APNS_USE_SANDBOX=false; the .p8 is NOT committed.
  • ~/platform/.env.production has APPLE_SIGN_IN_CLIENT_ID=ai.curateme.familymanager (or relies on the bundle-id default).
  • Backend docker exec curateme-backend-b2b reports APNs configured True; POST /session/apple returns a cm_sk_* (200).
  • DELETE /session teardown verified (Step 6) — Apple 5.1.1(v) satisfied.

Apple portal:

  • Team ID recorded (Step 1).
  • Explicit App ID ai.curateme.familymanager with Push + Sign in with Apple + App Groups (group.ai.curateme.familymanager) enabled (Step 2).
  • APNs .p8 created, 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 CLLocationManager started), no Audio (Speech is on-device; only the transcript text leaves), no Photos (Vision OCR on-device; only extracted text leaves); collected = User ID (Apple sub), 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:false receipt surfaces a loud FAILED, never a silent success.
  • A capture with third_party_ai=false returns 403 consent_required; PUT /session/consent unblocks it.

Rollback / If it goes wrong

SymptomLikely causeFix
Push never arrives on a TestFlight buildAPNS_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 FalseOne of APNS_KEY_P8 / APNS_KEY_ID / APNS_TEAM_ID / APNS_TOPIC missingRe-check all five env vars (apns_notifier.py:211-213); redeploy backend
POST /session/apple returns 401 audience mismatchToken aud ≠ bundle idSet 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 signSecrets.xcconfig missing or not referenced via configFilesRecreate the xcconfig with the real DEVELOPMENT_TEAM; add the configFiles block (Step 3)
App Group / keychain read goes dark in the widget or share extensionThe App Group wasn’t registered on the App ID, or the entitlements lists driftedRegister 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).