iOS team change: exact specs for the prep work¶
Decisions made after this spec (2026-09-25). Where they differ, they override the text below. - Item B (Nutri-E device identity) is dropped. The operator is Nutri-E's only paying subscriber: the production
SUBSCRIPTIONSstore holds exactly 1 record. After the first Dashecorp update he taps Restore Purchases once. Free users get a fresh free allowance, and their logged data stays on the phone. No App Group, no migration release. - Item D (Reward-E) shrinks to a one-off script. Only the operator's family pays. The script keeps Apple'stransfer_subpath, so Firebase UIDs and the family data are preserved, but it drops the GitHub Actions tool, the_siwaMigrationstate machine and the daily re-runs. The operator runs it twice, with the Sign in with Apple keys. - No Count-E pilot first. All four apps transfer together, once the Dashecorp membership is active. - Item A drops the team-suffixed ASC secrets (A0a). Secret names stay the same, and the values are swapped at cutover.
This spec covers the code and config work for the App Transfer of Nutri-E, Fast-E, Count-E and Reward-E from Invotek AS
(Team ID V9GL74NMBR) to Dashecorp AS (Team ID unknown, written NEW_TEAM). It builds on ios-app-transfer.md (the
program reference) and ios-transfer-factbase.json, and does not repeat their eligibility and cleanup rules.
- Revision 2. Eight verifiers tried to refute revision 1 against the code and the primary sources. The results are
in
~/jobs/ios-prep-spec-v2/verify.json, and each of the 48 flagged items is handled here.changes.mdgives one line per item. Design bugs are fixed in the design, the pseudocode, the tests and the issue drafts: - the Matchfile, the Swift regex and the guard (A);
- the B2 retry and latch, the migrated-ID case, and the refund path (B);
- the APNs PATCH rule (C);
- the SIWA window and the revoked-user gate, and the App Check window (D).
- Code read: read-only clones in
~/repos/ios-move/: nutri-e 3c82647, reward-e f01b46c, count-e 5325986, fast-e 80bc166, drink-e 671dc66, heart-e 0cfb397, ios-sdk 5d55132, dashe-cutie 20bfc6a. - References:
repo/path:lineat those commits. Thedashe-prefix is dropped where it is unambiguous. - Evidence tags: VERIFIED means an Apple, Firebase, RevenueCat, GitHub, XcodeGen or fastlane page or source states it (URL given), or the code shows it (file:line). UNVERIFIED means no primary source states it, or it is our inference. "(review)" marks a source the verifiers fetched that this revision did not fetch again.
- Quotes: the Apple quotes were taken on 2026-09-25 through Apple's DocC JSON endpoints and the forums. Check the punctuation before pasting any of them into user-facing text.
- Secrets: none are reproduced here. Where a file holds one, only the file:line is given.
0. Read this first¶
0.1 Terms¶
| Term | Meaning |
|---|---|
NEW_TEAM |
Dashecorp's 10-character Apple Team ID, not yet known |
| T0 | Invotek's Account Holder initiates the transfer of one app |
| TA | Dashecorp's Account Holder accepts the transfer (recorded as transfer_accepted_at) |
| T1 | The transfer is complete: "Processing App Transfer" (up to 2 business days after TA) is done |
| U1 | The first NEW_TEAM-signed update of that app is released on the App Store |
| NB1 | The first NEW_TEAM-signed build of that app runs anywhere: TestFlight, App Review, then U1. NB1 comes before U1 |
| 60-day window | TA to TA+60 days, the stricter of Apple's two anchors (D.2.4). Sign in with Apple transfer identifiers can be generated from before T0 until TA+60, and exchanged from T1 until TA+60 |
0.2 What breaks, and when¶
The distinction that matters is between T1 (server-side credentials stop working) and U1 (on-device state tied to
the App ID prefix is lost). Nothing on the device changes at T1. A device that runs a NEW_TEAM-signed TestFlight build
(NB1) hits the U1 effects on that device earlier.
| Dependency | Apps | Breaks at | Evidence |
|---|---|---|---|
| CI upload with the Invotek ASC key and Invotek profiles | all 4 | T1 | The app leaves the sender's account (Overview of app transfer, VERIFIED). New profiles are needed because "the transferred App ID will now have your Team ID as the App ID prefix" (TN2311, VERIFIED). How the upload fails is UNVERIFIED |
Cuti-E reply pushes signed with Invotek APNs key 635XA3L42Q |
Nutri-E, Fast-E, Reward-E (per the fact base; the key-to-app mapping is D1 data, UNVERIFIED, see C.1) | T1, after a short propagation delay | VERIFIED, Apple DTS: "After a short (but non-zero) propagation delay following the completion of the transfer, the credentials of the old team (either certificates or provider tokens) will stop working", and "once the transfer is complete, the old team can no longer send pushes to the app regardless of version, update, or install state" (forum 744468). DTS says the same in 760355 and 795723 (review). Caveat from 795723 (review): open connections from the old team "might still be able to send notifications for a while", so a test push that succeeds at T1 proves nothing until connections are recycled (C.3) |
| RevenueCat transaction validation with the Invotek In-App Purchase key | Nutri-E, Reward-E | around T1 | RevenueCat staff: "As soon as the Apple Developer Account change is complete, update the in-app purchase key in RevenueCat" (community, 2026-02-20, VERIFIED staff answer). The quote says when to update the key, not when validation breaks. The break time is inference (UNVERIFIED), and it may start while the transfer is processing. Both apps use StoreKit 2 (purchases-ios 5.x default), so the shared-secret fallback probably does not help and purchases likely fail until the Dashecorp key is in (E) |
Sign in with Apple sub and relay email |
Reward-E | T1 | "After the app transfer is completed, the ID token will contain the user ID scoped to the recipient team" (Apple DTS, forum 765778, VERIFIED) |
| Keychain items in the default access group | Nutri-E device ID; Reward-E Firebase Auth session; Cuti-E device token (all apps) | U1 (per device: first run of a NEW_TEAM-signed build) |
"The keychain access will only be lost after an update to the app is released on the App Store" (TN2311, VERIFIED). Firebase Auth 10.29.0, which Reward-E pins, keeps its session in a keychain item with no access group: VERIFIED from the SDK source (FIRAuthKeychainServices.m@10.29.0, genericPasswordQueryWithKey: sets only class, account and service; see D.1.4) |
| App Attest RP ID (Firebase App Check) | Reward-E | NB1 for App Check validation; U1 for the user base | The RP ID is "A hash of your app's App ID, which is the concatenation of your app's App ID prefix, a period, and your app's CFBundleIdentifier value" (Validating apps, VERIFIED). Firebase holds a single Team ID per iOS app (IosApp.teamId, review), so one of the two build populations fails validation at any moment (D.4) |
| App Groups (widgets; Nutri-E keychain group) | Count-E, Fast-E, Reward-E; Nutri-E after B | U1. The group can only move after T1 | "Once an app has completed transfer, the app group can be deleted from the transferor's account and registered to the recipient's account" (Overview of app transfer, VERIFIED). The delete-then-register gap is a steal window (B.4, A.6) |
| StoreKit config team and the Reward-E test that asserts it | Nutri-E, Reward-E | the cutover PR (CI goes red) | reward-e/ios/RewardETests/SubscriptionConfigValidationTests.swift:150-156 |
| App Store Server Notifications URL | Nutri-E, Reward-E | UNVERIFIED | Apple does not say whether the URL survives a transfer. Re-check it at T1 |
0.3 Corrections to ios-app-transfer.md found in this pass¶
Fold these into the reference doc.
- Nutri-E sets the team in 8 places, not 1.
DEVELOPMENT_TEAM = V9GL74NMBRappears atnutri-e/ios/Nutri-E/Nutri-E.xcodeproj/project.pbxproj:315,379,411,453,491,513,534,554: project, app, unit-test and UI-test targets, Debug and Release each. - Nutri-E's "device secret" is not a secret.
getDeviceSecret()computes SHA-256 of the device ID plus a salt that is compiled into the app (nutri-e/ios/Nutri-E/Nutri-E/Services/DeviceAuthService.swift:14-16,50-65,79-87). The worker checks the same hash (nutri-e/cloudflare-worker-openai/worker.js:378-384,594-605). Only the device ID needs to survive. Knowing a device ID is enough to act as that device. This is a pre-existing weakness, filed separately (see Open questions). The review found the salt in plaintext in 4 more repo files, which strengthens "not a secret". - The App Group keychain method is now Apple DTS guidance, not an untested idea. Quinn "The Eskimo!" rewrote the "Transfer Your App to Another Team" section of thread 706128 on 2026-03-31. VERIFIED (Apple employee).
- Its step 7 is the claim that matters: after the transferee's update, the app "will have access to your app group, and hence your keychain items".
- The survival depends on the app group itself being transferred (step 5).
- The sentence "an app group is tied to your team, not your App ID prefix" is from the same thread's other section, "Migrate From a Unique App ID Prefix to Your Team ID". That section covers a prefix change inside one team, so it is not the evidence for a transfer.
- Quinn also calls the method "bleeding edge" (thread 821062).
- The caveats are listed in B.4.
- The Cuti-E SDK needs no change for the transfer:
- No dashe app turns on App Attest. All call
configure(appId:), and the default isuseAppAttest: false(ios-sdk/Sources/CutiE/CutiE.swift:57on main 2.4.1;:56in 2.3.1, the version the apps ship). Call sites: e.g.nutri-e/.../Services/CutiEService.swift:37,reward-e/ios/RewardE/Services/CutiEService.swift:26. - The public SDK has no DeviceCheck code at all.
CutiEDeviceCheck.swiftexists only in the stale private copydashe-cutie/ios/Sources/. Caveat:dashe-cutie/ios/release-public.sh:34-35replaces the public SDK'sSources/with that stale copy, so running it would bringCutiEDeviceCheck.swiftback. Do not run it during the migration. - A lost device token is re-issued unchanged, because re-registration returns the existing token
(
dashe-cutie/cloudflare-worker/src/handlers/device-tokens.js:75-99). - So the "per-team DeviceCheck credentials" code change in the reference doc is not needed.
- Do not change the Cuti-E global
APNS_*env at a dashe cutover. It serves only the Cuti-E Admin fallback and the Feedback App (src/utils/notifications/apns.js:83-92,queue.js:650-658,719-726). Both stay with Invotek for now. When the reference doc's later step recreates Cuti-E Admin and the Feedback App under Dashecorp, that step changes the env. - APNs breaks at T1, not at U1, and this is now VERIFIED by Apple DTS (0.2). Apple also says: "For transferred apps, close and recreate any existing connections from your push provider server to APNs. If you don't, APNs can't start accepting push requests from the new team", and "You can't map a connection to APNs to multiple teams. If you manage APNs for multiple developer accounts, open separate connection pools for each of them" (APNs token auth, VERIFIED). After Fast-E's T1 the Cuti-E worker serves both teams, so per-team connection separation is a requirement (C.2).
- Reward-E's DeviceCheck fallback is dead code. The deployment target is iOS 16.0 (
reward-e/ios/project.yml:5) and the fallback runs only below iOS 14 (AppCheckService.swift:64-68). No Firebase DeviceCheck key is needed. - Reward-E signs out every signed-in iOS user once, per device, when the device first runs a
NEW_TEAM-signed build. - That starts at U1, and earlier for TestFlight testers.
- Firebase Auth keeps its session in the default keychain group. This is VERIFIED from the SDK source (D.1.4).
- Free-tier users with local data only are not affected: they need no Firebase session
(
reward-e/ios/RewardE/Models/AppState.swift:185-187). Android users are not affected either. - So the Sign in with Apple re-link must finish before U1, not just within 60 days.
- Two more docs name the team:
fast-e/CLAUDE.md:212,260anddrink-e/CLAUDE.md:130. The reference doc's "Docs naming the team" list misses both. - Nutri-E's StoreKit config has a wrong app ID.
_applicationInternalIDis6738736024, but the ASC app is6753125391(nutri-e/ios/Nutri-E/Nutri-E/Configuration.storekit:10). This is pre-existing drift; fix it in A6. - Reward-E's dev Firebase project is
reward-e-dev, notreward-e-app-dev. Sources:reward-e/.firebaserc,ios/RewardE/GoogleService-Info-Dev.plist:14,README.md:61andios/project.yml:146. Only the stalereward-e/CLAUDE.md:297saysreward-e-app-dev. - Nutri-E's live RevenueCat project is
proj2c32896f.proj61d576beis archived: "Archived projectproj61d576bedo not use" (nutri-e/docs/subscriptions.md:22-31). The fact base's "no In-App Purchase key" observation came from the archived project and says nothing about the live one (E).
A. Team ID as one setting per app repo (all 6 app repos)¶
A.1 Inventory: every place the team is hard-coded¶
In the Change column, del means delete the line, read means it already uses $TEAM_ID and now gets it from the
xcconfig, and mirror means the value must equal the xcconfig value (the guard enforces this). The review confirmed
every line number below except the corrections now folded in (heart-e secrets, drink-e generate-match-profiles.yml).
| Repo | File:line | What | Change |
|---|---|---|---|
| count-e | ios/project.yml:31 |
DEVELOPMENT_TEAM: V9GL74NMBR |
del, and add configFiles |
| count-e | .github/workflows/build-and-upload.yml:14 |
env TEAM_ID: V9GL74NMBR |
del, and add a read step |
| count-e | same file :63, :117, :128 |
match team_id, xcodebuild DEVELOPMENT_TEAM, ExportOptions teamID |
read |
| count-e | same file :57-67 |
match git_url with no git_branch (the default master is Invotek's store) |
add git_branch:"$TEAM_ID" |
| count-e | same file :77-79,146-147 |
secrets.ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_P8_BASE64 (Invotek ASC key) |
select by team |
| count-e | CLAUDE.md:147 |
doc | point to the xcconfig |
| fast-e | ios/project.yml:63 |
DEVELOPMENT_TEAM |
del |
| fast-e | .github/workflows/build-and-upload.yml:14 / :63,:117,:128 / :77-79,:146-147 |
env / uses / ASC secrets | del / read / select by team |
| fast-e | CLAUDE.md:212,260 |
doc | point to the xcconfig |
| heart-e | ios/project.yml:31 |
DEVELOPMENT_TEAM |
del |
| heart-e | .github/workflows/build-and-upload.yml:14 / :63,:121,:133 / :77-79,:151-152 |
env / uses / ASC secrets. The upload-step secrets are at :151-152, not :146-147: heart-e's archive step has four extra MARKETING_VERSION lines |
del / read / select by team |
| heart-e | CLAUDE.md:118 |
doc | point to the xcconfig |
| drink-e | ios/project.yml:30 |
DEVELOPMENT_TEAM |
del |
| drink-e | .github/workflows/build-and-upload.yml:14 / :63,:117,:128 / :77-79,:146-147 |
env / uses / ASC secrets | del / read / select by team |
| drink-e | .github/workflows/generate-match-profiles.yml:4,6,16,121,147-148 |
comments and a step name that name the team, cert C8788TMK34 and branch master as the store |
rewrite: the store is the branch named after the team |
| drink-e | same file :30-33, :34-37 |
team_id input default V9GL74NMBR; expected_cert_id default C8788TMK34 |
required inputs, no defaults |
| drink-e | same file :157-159 |
team_id:"$TEAM_ID", git_branch:"master" |
git_branch:"$TEAM_ID" |
| drink-e | same file :68-69,:80-81,:125-126,:167-168 |
secrets.ASC_* in four steps |
select by team_id |
| drink-e | CLAUDE.md:130 |
doc | point to the xcconfig |
| reward-e | ios/project.yml:82 |
DEVELOPMENT_TEAM |
del |
| reward-e | .github/workflows/build-and-upload.yml:14 / :63,:117,:128 / :77-79,:146-147 |
env / uses / ASC secrets | del / read / select by team |
| reward-e | ios/fastlane/Matchfile:5 |
team_id("V9GL74NMBR") |
del (CI passes team_id and git_branch explicitly; see A.3) |
| reward-e | ios/RewardE/Configuration.storekit:11 |
"_developerTeamID" : "V9GL74NMBR" |
mirror |
| reward-e | ios/RewardETests/SubscriptionConfigValidationTests.swift:150-156 |
test asserts the literal | compare to the xcconfig |
| reward-e | scripts/validate-subscription-config.sh:113-118 |
greps the literal | compare to the xcconfig |
| reward-e | .github/workflows/subscription-validation.yml:5-13 |
path filter lacks ios/Team.xcconfig, which the validator will read |
add the path (push and pull_request) |
| reward-e | CLAUDE.md:365, AppStore/SUBMISSION_CHECKLIST.md:183 |
doc | point to the xcconfig |
| nutri-e | ios/Nutri-E/Nutri-E.xcodeproj/project.pbxproj:315,379,411,453,491,513,534,554 |
8 × DEVELOPMENT_TEAM = V9GL74NMBR; |
del, and add baseConfigurationReference |
| nutri-e | .github/workflows/build-and-upload.yml:13 / :57,:168,:179 / :59 / :67-69,:196-197 |
env / uses / git_branch:"master" / ASC secrets |
del / read / "$TEAM_ID" / select by team |
| nutri-e | ios/Nutri-E/fastlane/Appfile:5,8, Fastfile:41,96 |
legacy fastlane lanes; CI does not use them | delete the files |
| nutri-e | ios/ExportOptions.plist:8 (and a legacy profile at :25-26), ios/Nutri-E/ExportOptions.plist:8 |
legacy; CI generates its own ExportOptions (build-and-upload.yml:174-188) |
delete |
| nutri-e | ios/Nutri-E/deploy.sh:15, deploy-api.sh:15, test-build.sh:15 |
read the legacy ../ExportOptions.plist |
delete |
| nutri-e | scripts/setup-testflight-secrets.sh:28, ios/Nutri-E/setup.sh:41 (comment) |
legacy | delete, or drop the line |
| nutri-e | ios/Nutri-E/Nutri-E/Configuration.storekit:11 |
_developerTeamID |
mirror; also fix :10 to 6753125391 |
| nutri-e | ios/Nutri-E/DEPLOYMENT.md:67, docs/testflight.md:181 |
doc | point to the xcconfig |
| count-e, drink-e, heart-e, nutri-e | .github/workflows/monitor-xcode-cloud.yml |
dead (references secrets that are not set, per the fact base) | delete |
- Found nowhere in the six app repos:
AppIdentifierPrefix,TeamIdentifierPrefix,keychain-access-groups, iCloud containers. Checked by grep; see also the fact base. - Found only in dashe-cutie (Cuti-E apps, recreated later, outside this item):
- signing:
ios-admin-app/.../project.pbxproj:369,390,533,565, andsuper-admin-app/ios/project.yml:15andproject.pbxproj:138,271,339; - docs, examples and placeholders that sign nothing:
GOOGLE_AUTH.md:17,CLAUDE.md:630(APNS_TEAM_IDexample),admin-dashboard/mock-apns-settings.html:233(input default) andandroid-admin-app/.../ui/screens/AppDetailScreen.kt:497(placeholder text). ios-sdkhas no hits.
A.2 Design: one file per repo¶
One file holds the team: ios/Team.xcconfig (Nutri-E: ios/Nutri-E/Team.xcconfig, next to the .xcodeproj).
// Apple Developer team that signs this app. Moving the app to another team is a one-line change here.
// Xcode, CI (TEAM_ID), fastlane match (team and git branch), ExportOptions, the ASC secret selection and
// scripts/check-team-id.sh all read this file. Never write the Team ID anywhere else.
DEVELOPMENT_TEAM = V9GL74NMBR
- Xcode. Each target inherits
DEVELOPMENT_TEAMfrom the project-level xcconfig. The per-targetCODE_SIGN_STYLEandPROVISIONING_PROFILE_SPECIFIERstay as they are:"match AppStore <bundle>"names the bundle, not the team. - XcodeGen apps use a root
configFileskey. VERIFIED: it is documented in XcodeGen'sProjectSpec.md, and a local XcodeGen and xcodebuild run resolved the xcconfig team on all 4 Count-E targets. Flipping only the xcconfig changed all four (review). - Nutri-E sets
baseConfigurationReferenceon its two project-level configurations. - CI. A step right after checkout reads the xcconfig, checks that it has exactly one
DEVELOPMENT_TEAMline, and exportsTEAM_IDtoGITHUB_ENV. All existing$TEAM_IDuses stay unchanged. - fastlane match: one git branch per team in
dashecorp/ios-certs, named after the Team ID (git_branch:"$TEAM_ID"). - VERIFIED: fastlane documents
git_branchfor multiple teams, the default branch ismaster, and match creates a new orphan branch on its first write run (fastlane docs and source, review). - Prep: create branch
V9GL74NMBRfrommaster(same content, so nothing needs decrypting). Without it, the first readonly run would meet an empty orphan branch. The first Dashecorp match write run creates branchNEW_TEAM. MATCH_PASSWORDis a repo passphrase, not an Apple credential, so both branches can use the same one.- Explicit parameters win over Matchfile values. VERIFIED: fastlane's
ConfigurationFile#method_missingbegins withreturn if self.options.key?(method_sym)(configuration_file.rb). - ASC API key: selected by team. The secrets get team-suffixed names, provisioned by OpenTofu:
ASC_KEY_ID_<TEAM>,ASC_ISSUER_ID_<TEAM>,ASC_KEY_P8_BASE64_<TEAM>. Workflows read${{ secrets[format('ASC_KEY_ID_{0}', env.TEAM_ID)] }}. - This lets the Dashecorp key be provisioned before T1, so the cutover PR stays one line.
- VERIFIED: the GitHub docs' contexts table allows both
secretsandenvin stepenv, and the actions/runner source shows thatGITHUB_ENVvalues reach theenvcontext of later steps (review). The docs never say the second part outright, so the Count-E PR's CI run is still the proof. - Fallback, if it does not work: keep today's names and swap the values in
dashe-secrets.tfat each cutover. That costs one OpenTofu apply per app, synchronised with the PR. - StoreKit config files (Nutri-E, Reward-E) are JSON written by Xcode's App Store Connect sync and cannot include an xcconfig. They keep a mirror of the value, and the guard fails CI if the mirror differs. The cutover in these two repos is therefore two lines in one PR.
- Guard.
scripts/check-team-id.shruns in its own workflow,.github/workflows/team-id-guard.yml, which triggers on every pull request and every push tomainwith no path filter. Make it a required check. - Why not
ci.yml: in all five XcodeGen reposci.ymlruns only forios/**and.github/workflows/ci.yml(count-e/.github/workflows/ci.yml:3-8, the same in fast-e, heart-e, drink-e and reward-e). A PR that touches only a workflow or a script would skip the guard, and those are exactly the files where the team was hard-coded. - Nutri-E has no
ci.yml; its PR workflows aretest.ymlandlint.yml. - What it checks:
Team.xcconfighas exactly one validDEVELOPMENT_TEAMline;- no Team ID sits next to a team key outside
Team.xcconfig; - the current and every past team value, read from the xcconfig's git history, appears nowhere outside the allowed files. This catches a leftover old Team ID after a cutover even when no team key is on the same line;
- the StoreKit mirrors match;
- a
git greperror fails the job and is never read as "no match".
Cutover per app (at T1, after the Dashecorp cert, profiles, secrets and App Groups exist):
Plus _developerTeamID in the .storekit mirror for Nutri-E and Reward-E.
A.3 Exact changes (Count-E shown in full; the other repos differ only in the lines listed in A.1)¶
ios/project.yml (XcodeGen apps):
settings:
base:
MARKETING_VERSION: "1.2.0"
CURRENT_PROJECT_VERSION: "1"
- DEVELOPMENT_TEAM: V9GL74NMBR
CODE_SIGN_STYLE: Automatic
@@
+configFiles:
+ Debug: Team.xcconfig
+ Release: Team.xcconfig
.github/workflows/build-and-upload.yml:
env:
APP_IDENTIFIER: no.invotek.CountE
SCHEME: CountE
- TEAM_ID: V9GL74NMBR
@@
- uses: actions/checkout@v4
+
+ - name: Read signing team (ios/Team.xcconfig is the only source)
+ run: |
+ CFG=ios/Team.xcconfig
+ [ "$(grep -cE '^[[:space:]]*DEVELOPMENT_TEAM[[:space:]]*=' "$CFG")" = 1 ] \
+ || { echo "::error::$CFG needs exactly one DEVELOPMENT_TEAM line"; exit 1; }
+ TEAM_ID=$(sed -n 's/^DEVELOPMENT_TEAM[[:space:]]*=[[:space:]]*\([A-Z0-9]\{10\}\)[[:space:]]*$/\1/p' "$CFG")
+ [ -n "$TEAM_ID" ] || { echo "::error::$CFG needs 'DEVELOPMENT_TEAM = <10-char Team ID>'"; exit 1; }
+ echo "TEAM_ID=$TEAM_ID" >> "$GITHUB_ENV"
@@ (match step)
team_id:"$TEAM_ID" \
+ git_branch:"$TEAM_ID" \
git_url:"https://github.com/dashecorp/ios-certs" \
@@ (build-number step :77-79 and upload step :146-147; heart-e :151-152)
- ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
- ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
- ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
+ ASC_KEY_ID: ${{ secrets[format('ASC_KEY_ID_{0}', env.TEAM_ID)] }}
+ ASC_ISSUER_ID: ${{ secrets[format('ASC_ISSUER_ID_{0}', env.TEAM_ID)] }}
+ ASC_KEY_P8_BASE64: ${{ secrets[format('ASC_KEY_P8_BASE64_{0}', env.TEAM_ID)] }}
run: |
+ [ -n "$ASC_KEY_ID" ] || { echo "::error::No ASC key provisioned for team $TEAM_ID (secret ASC_KEY_ID_$TEAM_ID)"; exit 1; }
.github/workflows/team-id-guard.yml (new, every repo):
name: Team ID guard
on:
pull_request:
push:
branches: [main]
jobs:
team-id-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the guard reads past Team IDs from the history of Team.xcconfig
- run: scripts/check-team-id.sh # Nutri-E: scripts/check-team-id.sh ios/Nutri-E/Team.xcconfig
Nutri-E project.pbxproj (no XcodeGen):
- Add a
PBXFileReferenceforTeam.xcconfig, withlastKnownFileType = text.xcconfig, to the main group. - Add
baseConfigurationReference = <ref> /* Team.xcconfig */;to the project-level Debug and ReleaseXCBuildConfigurationblocks. These are the ones starting at:281and:345, referenced by theBuild configuration list for PBXProject "Nutri-E"at:572. - Delete all 8
DEVELOPMENT_TEAM = V9GL74NMBR;lines. - In
build-and-upload.yml: - delete
:13; - add the read step with
CFG=ios/Nutri-E/Team.xcconfig; - change
:59fromgit_branch:"master"togit_branch:"$TEAM_ID"; - switch
:67-69and:196-197to the team-suffixed secrets.
Reward-E extras:
ios/fastlane/Matchfile: delete:5team_id("V9GL74NMBR")and do not read the xcconfig from Ruby.- Revision 1 used
File.expand_path("../Team.xcconfig", __dir__). That breaks every Reward-E TestFlight build. - fastlane evaluates the Matchfile with a bare
eval(content)(VERIFIED, configuration_file.rb), so__dir__is nil. The path then resolves against the cwd, which isios/(build-and-upload.yml:50), and becomes<repo>/Team.xcconfig.File.readraisesErrno::ENOENT, and fastlane re-raises it. The review reproduced this on Ruby 2.6.10 and 3.4.6. - CI already passes
team_id:"$TEAM_ID"(:63) and, after A5,git_branch:"$TEAM_ID". Explicit parameters win (A.2.3). Add a comment in the Matchfile instead: ios/RewardETests/SubscriptionConfigValidationTests.swift:150-156replacestestStoreKitConfigTeamId.- The file already finds sources through
#filePath(:65-69). - Reward-E builds in Swift 5 mode (
ios/project.yml:84SWIFT_VERSION: "5.9") withoutSWIFT_ENABLE_BARE_SLASH_REGEX, so a bare/…/regex literal does not parse. Use the extended delimiter#/…/#. The review compiled both forms withswiftc -swift-version 5: the bare form fails with "expected expression", and#/…/#compiles. - A missing file fails the test instead of skipping it, so a wrong path cannot pass silently.
func testStoreKitConfigTeamIdMatchesTeamXcconfig() throws { let config = try loadStoreKitConfig() let xcconfig = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() // RewardETests/ .deletingLastPathComponent() // ios/ .appendingPathComponent("Team.xcconfig") let text = try String(contentsOf: xcconfig, encoding: .utf8) // Extended delimiters: this target builds in Swift 5 mode without bare-slash regex literals. let team = try XCTUnwrap( text.firstMatch(of: #/(?m)^DEVELOPMENT_TEAM\s*=\s*([A-Z0-9]{10})\s*$/#)?.1, "ios/Team.xcconfig needs one 'DEVELOPMENT_TEAM = <Team ID>' line" ) XCTAssertEqual(config.settings._developerTeamID, String(team), "Configuration.storekit _developerTeamID must mirror ios/Team.xcconfig") } scripts/validate-subscription-config.sh:113-118: readTEAM=$(sed -n ...)fromios/Team.xcconfig, with the same pattern as the CI read step, then grep for"_developerTeamID" : "$TEAM".subscription-validation.yml:5-13: addios/Team.xcconfigto both path lists.
generate-match-profiles.yml (the operator tool that lives in dashe-drink-e):
- Remove the defaults
V9GL74NMBR(:33) andC8788TMK34(:37).team_idandexpected_cert_idbecome required inputs. Whenallow_new_cert=true,expected_cert_idmay be empty. - Change
git_branch:"master"(:159) togit_branch:"$TEAM_ID". - Select the ASC secrets by
team_idin all four steps (:68-69,:80-81,:125-126,:167-168), the same way as in A.2. - Rewrite the comments and the step name that call
master"the store" (:4,:6,:16,:121,:147-148). The store is the branch named after the team, and the header comment names no team or cert. - Add a boolean input
allow_new_cert(defaultfalse). When it istrue, PRE-FLIGHT skips the "expected cert present" check, and POST-FLIGHT requires exactly one new distribution certificate. - This is how the first Dashecorp run creates Dashecorp's single shared distribution cert.
- The safety model at
:10-21stays as it is for every other run.
A.4 Tests to write first (TDD)¶
scripts/check-team-id.sh(new, each repo). Write it and add onlyTeam.xcconfig, then run it before any other change.- It must fail. It lists only the lines that contain the literal team value outside
*.md,docs/**and the allowed files. It does not list doc sites, the$TEAM_IDread sites, the missinggit_branch, the ASC secret sites,monitor-xcode-cloud.ymlor the cert default; A.1 remains the checklist for those. - Expected output, from the review's run of the revision 1 logic, which lists the same sites. A line appears once per
check that hits it:
- count-e:
build-and-upload.yml:14,ios/project.yml:31; - drink-e: those plus
generate-match-profiles.yml:6,33; - reward-e: those plus
Matchfile:5,SubscriptionConfigValidationTests.swift:153andvalidate-subscription-config.sh:114,115,117; - nutri-e (with the argument
ios/Nutri-E/Team.xcconfig): the 8 pbxproj lines,Appfile:5,8,Fastfile:41,96,setup.sh:41,setup-testflight-secrets.sh:28and bothExportOptions.plist:8.
- count-e:
- With no xcconfig at all, it fails even earlier, on the missing file. After the change it must pass.
#!/usr/bin/env bash
# The Apple Team ID lives only in Team.xcconfig. Fails with the list of violations.
# No `set -e`: git grep's exit status is inspected explicitly (0 = hits, 1 = none, other = error).
set -uo pipefail
CFG="${1:-ios/Team.xcconfig}" # Nutri-E passes ios/Nutri-E/Team.xcconfig
[ -f "$CFG" ] || { echo "::error::$CFG is missing"; exit 1; }
n=$(grep -cE '^[[:space:]]*DEVELOPMENT_TEAM[[:space:]]*=' "$CFG")
TEAM=$(sed -n 's/^DEVELOPMENT_TEAM[[:space:]]*=[[:space:]]*\([A-Z0-9]\{10\}\)[[:space:]]*$/\1/p' "$CFG")
if [ "$n" != 1 ] || [ -z "$TEAM" ]; then
echo "::error::$CFG needs exactly one 'DEVELOPMENT_TEAM = <10-char Team ID>' line (found $n)"; exit 1
fi
fail=0
check() { # $1 = message; the rest are git grep arguments
local msg=$1 out rc; shift
out=$(git grep -nE "$@"); rc=$?
case $rc in
0) echo "::error::$msg"; echo "$out"; fail=1 ;;
1) ;;
*) echo "::error::git grep failed (exit $rc); refusing to pass"; exit 2 ;;
esac
}
EXCL=(":(exclude)$CFG" ':(exclude)*.md' ':(exclude)docs/**' ':(exclude)scripts/check-team-id.sh')
KEYS='(DEVELOPMENT_TEAM|DevelopmentTeam|TEAM_ID|team_id|teamID|itc_team_id)'
check "Team ID hard-coded next to a team key outside $CFG:" \
"${KEYS}[^A-Za-z0-9]{1,6}[A-Z0-9]{10}([^A-Za-z0-9]|\$)" -- . "${EXCL[@]}"
# The current team plus every team this repo has used (history of $CFG; needs fetch-depth: 0).
TEAMS=$( { echo "$TEAM"; git log -p --format= -- "$CFG" \
| sed -n 's/^[-+]DEVELOPMENT_TEAM[[:space:]]*=[[:space:]]*\([A-Z0-9]\{10\}\).*/\1/p'; } | sort -u | paste -sd'|' -)
check "A current or past Team ID ($TEAMS) appears outside $CFG:" "($TEAMS)" -- . "${EXCL[@]}" ':(exclude)*.storekit'
while IFS= read -r f; do
v=$(sed -n 's/.*"_developerTeamID"[[:space:]]*:[[:space:]]*"\([A-Z0-9]*\)".*/\1/p' "$f")
[ "$v" = "$TEAM" ] || { echo "::error::$f _developerTeamID='$v' must mirror $CFG ($TEAM)"; fail=1; }
done < <(git ls-files '*.storekit')
exit $fail
Guard self-tests (a scripts/test-check-team-id.sh run by the same workflow, on a temporary copy of the repo):
1. Two DEVELOPMENT_TEAM lines → exit 1. Revision 1 passed this with exit 0 (review).
2. GIT_DIR=/nonexistent scripts/check-team-id.sh → exit 2. That is a git grep error, and it must not pass.
3. After a committed flip to ABCDE12345, leave V9GL74NMBR in a workflow comment → exit 1 (the past-team check).
4. The mirror differs → exit 1.
2. Resolved-settings check in the existing macOS CI job, after xcodegen generate. For every target, e.g. Count-E's
CountE, CountEWidget, CountETests and CountEUITests:
xcodebuild -project ios/<Proj>.xcodeproj -target "$T" -showBuildSettings | awk '$1=="DEVELOPMENT_TEAM"{print $3}'
Team.xcconfig and the .storekit mirror to ABCDE12345.
- The guard must pass.
- Every target must resolve ABCDE12345.
- The workflow's read step, run with bash -x locally, must export TEAM_ID=ABCDE12345.
- Then revert. This proves that one file is enough.
4. Reward-E: change SubscriptionConfigValidationTests first to compare against the xcconfig. It passes today
because both say V9GL74NMBR. It must fail when only one of the two is flipped. It must compile in CI, which is
what catches the regex-literal mode.
A.5 Before, at and after the transfer¶
| When | What |
|---|---|
| Before (now) | A0 (infra secrets and the ios-certs branch), then A1-A6 merged with Team.xcconfig = V9GL74NMBR. The guard is a required check. Signing does not change and CI stays green. Nothing depends on the Dashecorp account. |
When NEW_TEAM exists |
Operator: Dashecorp ASC API key, then ASC_*_<NEW_TEAM> secrets via OpenTofu, provisioned early |
| At T1 (per app) | Operator: register the App Group(s) (A.6); run generate-match-profiles with team_id=NEW_TEAM (allow_new_cert=true on the first app only) for the app and widget bundle IDs. Agent: the cutover PR (one line, or two with the StoreKit mirror). CI builds NB1, which becomes U1 |
| After | When no repo uses them any more, remove the _V9GL74NMBR secrets from that repo |
A.6 App Groups and widget App IDs: what moves, what the recipient re-creates¶
- App Groups do not move with the app. They are re-registered.
- VERIFIED: "Once an app has completed transfer, the app group can be deleted from the transferor's account and registered to the recipient's account. This won't impact the app's availability on the App Store." (Overview of app transfer)
- Names are unique across teams. VERIFIED: Xcode "guards against the reuse of app group names across teams" (Sharing keychain items). So Invotek deletes first, then Dashecorp registers the same identifier.
- Steal window. VERIFIED, Quinn in 706128: "The current transfer
process of app groups exposes a small window where some other team can 'steal' your app group ID. We have a bug on
file to improve that process (r. 171616887)."
- Do the move in one operator session, with both accounts signed in: Invotek deletes, and Dashecorp registers immediately.
- Then confirm the group in Dashecorp's Identifiers list before NB1 is built.
- If the identifier is taken, stop and contact Apple Developer Support. Do not ship a build with a new group ID without deciding on it first (B.9).
- iOS app group IDs carry no team prefix. VERIFIED: "Xcode doesn't prepend the app group with the team identifier".
- Keychain items in an app-group access group survive the move, as long as the app group itself is transferred (Apple DTS step 7, VERIFIED, see B.4).
- Shared-container data survives: UNVERIFIED. This is widget UserDefaults suites and
containerURLfiles. At worst the widget shows empty or stale data until the app writes again. - Widget extension App IDs (
no.invotek.CountE.Widget,.FastE.Widget,.RewardE.Widget): UNVERIFIED. - Apple speaks only of "its associated App ID" (singular).
- Forum threads 82600 and 820446, with no Apple staff answer, report extension IDs left behind.
- The Count-E pilot decides this. Right after its T1, check Certificates, Identifiers & Profiles in the Dashecorp account.
- If the widget ID is missing, contact Apple Developer Support.
- Last resort: a new widget bundle ID, e.g.
no.invotek.CountE.WidgetV2. Users then re-add the widget. WhetherNEW_TEAMmay register a newno.invotek.*ID is UNVERIFIED. - The recipient must re-create, per app:
- Register the App Group with the identical ID, in the same session in which Invotek deletes it (item 1).
- Enable App Groups on the app App ID and the widget App ID, and assign the group.
- Check that Push, HealthKit, Sign in with Apple and App Attest are still enabled on the transferred App IDs (UNVERIFIED whether capabilities carry over).
- Create a new distribution certificate and App Store profiles for the app and the widget. VERIFIED: TN2311 and Accept an app transfer.
- Keep every identifier string unchanged. The groups in use:
| Group | App | Where it appears |
|---|---|---|
group.no.invotek.CountE |
Count-E | CountE.entitlements:9, CountEWidget.entitlements:7, WidgetDataReader.swift:7, TallyService.swift:219 |
group.no.invotek.FastE |
Fast-E | FastE.entitlements:11, FastEWidget.entitlements:7, FastE/Services/WidgetDataProvider.swift:7, FastEWidget/WidgetDataProvider.swift:9 |
group.no.invotek.RewardE |
Reward-E | RewardE.entitlements:15, RewardEWidget.entitlements:7, WidgetSync/WidgetDataProvider.swift:34, RewardEWidget/WidgetDataProvider.swift:42 |
group.no.invotek.Nutri-E |
Nutri-E (new, see B) | — |
Drink-E and Heart-E are not transferred. They get new com.dashecorp.* groups when they are recreated.
7. At the NB1 submission, App Store Connect warns about a possible loss of keychain access. VERIFIED, Quinn: "That
warning is talking about keychain items in normal keychain access groups. Items in an AGI keychain access group will
still be accessible as long as you transfer the app group." Expect it for every app. It is not a reason to abort.
A.7 Risks¶
configFilesorsecrets[format()]does not behave as expected in this org. Both are now VERIFIED from docs and source (A.2), and the Count-E PR's CI still proves them. Fallbacks are given in A.2.- A match write run with the wrong ASC key or branch creates a stray certificate. Keep the PRE- and POST-FLIGHT
checks.
allow_new_certis used exactly once. - A staggered migration puts both teams in one org. Per-branch match stores and per-team secrets are what make that
safe.
dashe-cutie,rig-cockpit-iosanddashecorp/.githubios-testflight.ymlkeep the unsuffixed Invotek secrets until they are migrated. - The widget App ID stays behind (A.6.4). The pilot catches it.
- Another team takes an App Group ID in the delete-then-register gap (A.6.1). Mitigated by one-session moves.
A.8 Issue drafts¶
A0a. Repo dashecorp/infra. Title: "Team-scoped ASC API key secrets for the iOS apps (prep for the team change)"
The six dashe-* app workflows will select their App Store Connect API key by Apple Team ID (
secrets[format('ASC_KEY_ID_{0}', env.TEAM_ID)]), so moving an app to Dashecorp's team is a one-line repo change.
- Provision
ASC_KEY_ID_V9GL74NMBR,ASC_ISSUER_ID_V9GL74NMBRandASC_KEY_P8_BASE64_V9GL74NMBRingithub/dashecorp/dashe-secrets.tffor dashe-count-e, dashe-fast-e, dashe-heart-e, dashe-drink-e, dashe-reward-e and dashe-nutri-e. They use the same backing values as today'sASC_*.- Keep the unsuffixed
ASC_*secrets: dashe-cutie, rig-cockpit-ios and dashecorp/.github still use them.- Phase 2, when the Dashecorp key exists: add
ASC_*_<NEW_TEAM>to the same repos.Acceptance: - [ ]
tofu planshows only the 18 added secrets and no changes to existing ones. - [ ]gh secret liston each repo shows the three suffixed names. - [ ]secrets-registry.mdis updated.Est.: ~40 LOC HCL.
A0b. Repo dashecorp/ios-certs (operator or iBuild-E with write access). Title: "Add match branch V9GL74NMBR (copy of master)"
Workflows move to
git_branch: <Team ID>. Create branchV9GL74NMBRat the currentmasterhead. This is a git-only operation and nothing is decrypted. Without it, match would create an empty orphan branch and the readonly runs would fail.After this, profile renewals for the six dashe apps are written to
V9GL74NMBR, while dashe-cutie, rig-cockpit-ios and dashecorp/.github keep readingmaster. Both branches hold the same shared distribution certificate, so the divergence is harmless. Write it down in the ios-certs README.Acceptance: - [ ]
git ls-remoteshowsrefs/heads/V9GL74NMBRequal tomaster. - [ ] A readonlymatch appstorerun withgit_branch:V9GL74NMBRsucceeds (the first A1 CI run shows it).Est.: 0 LOC.
A1. Repo dashecorp/dashe-count-e. Title: "Read the Apple Team ID from ios/Team.xcconfig only (team-change prep)"
Why: the app moves from team V9GL74NMBR to Dashecorp by App Transfer. The team is hard-coded at
ios/project.yml:31and.github/workflows/build-and-upload.yml:14(used at:63,:117,:128). After this issue, the cutover is one line.Do: 1. First, write
scripts/check-team-id.shand its self-tests (spec A.4),.github/workflows/team-id-guard.yml(every PR and push to main, no path filter,fetch-depth: 0), andios/Team.xcconfigcontainingDEVELOPMENT_TEAM = V9GL74NMBR. The guard fails, listingios/project.yml:31andbuild-and-upload.yml:14. 2. AddconfigFiles(Debug and Release pointing toTeam.xcconfig) toios/project.yml. Delete:31. 3. In the workflow: - deleteenv TEAM_ID; - add the "Read signing team" step after checkout, with the exactly-one check; - addgit_branch:"$TEAM_ID"to the match loop (:57-67); - selectASC_KEY_ID_/ASC_ISSUER_ID_/ASC_KEY_P8_BASE64_<TEAM_ID>at:77-79and:146-147, with an empty-check. 4. Add the showBuildSettings check for CountE, CountEWidget, CountETests and CountEUITests to the macOS CI job. 5.CLAUDE.md:147: "Team ID: seeios/Team.xcconfig". Delete.github/workflows/monitor-xcode-cloud.yml(dead). 6. After merge: makeTeam ID guarda required status check onmain.Acceptance: - [ ] The guard and its self-tests pass. A PR that touches only a workflow file runs the guard. - [ ] Every target resolves
DEVELOPMENT_TEAM = V9GL74NMBR. - [ ] The flip test (spec A.4.3) is done and its result is written in the PR. - [ ]build-and-upload.yml(workflow_dispatch) uploads a TestFlight build, proving the branch and secret selection. - [ ]git grep V9GL74NMBR -- ':!*.md' ':!docs/**'hits onlyios/Team.xcconfig.Depends on: A0a, A0b. Est.: ~110 LOC added, ~60 removed.
A2. dashecorp/dashe-fast-e: same as A1. Sites: ios/project.yml:63, workflow :14 (uses :63,:117,:128; secrets
:77-79,:146-147), CLAUDE.md:212,260. Targets for the showBuildSettings check are the app, widget and test targets
in ios/project.yml. Est.: ~110 LOC.
A3. dashecorp/dashe-heart-e: same as A1. Sites: ios/project.yml:31, workflow :14 (uses :63,:121,:133;
secrets :77-79 and :151-152), CLAUDE.md:118, monitor-xcode-cloud.yml. Heart-E is not transferred. It is
recreated as com.dashecorp.*, and Team.xcconfig makes that switch one line too. Est.: ~110 LOC.
A4. dashecorp/dashe-drink-e: same as A1. Sites: ios/project.yml:30, workflow :14 (uses :63,:117,:128;
secrets :77-79,:146-147), CLAUDE.md:130, monitor-xcode-cloud.yml. Also the generate-match-profiles.yml changes
in spec A.3:
- no team or cert defaults (:33, :37); required inputs;
- git_branch = team (:159);
- ASC secrets selected by team in all four steps (:68-69, :80-81, :125-126, :167-168);
- comments and the step name at :4, :6, :16, :121 and :147-148 no longer name master, the team or the cert;
- allow_new_cert with an exactly-one-new-cert post-check.
Acceptance adds:
- [ ] A dispatch with team_id=V9GL74NMBR, expected_cert_id=C8788TMK34, app_identifiers=no.invotek.DrinkE,
allow_new_cert=false passes PRE- and POST-FLIGHT with no new certificate.
- [ ] git grep C8788TMK34 -- ':!*.md' ':!docs/**' has no hits.
Est.: ~160 LOC.
A5. dashecorp/dashe-reward-e: same as A1. Sites: ios/project.yml:82, workflow :14 (uses :63,:117,:128;
secrets :77-79,:146-147), ios/fastlane/Matchfile:5, CLAUDE.md:365, AppStore/SUBMISSION_CHECKLIST.md:183. In
addition:
- Matchfile:5 is deleted, with a comment explaining why. The Matchfile does not read the xcconfig (spec A.3).
- SubscriptionConfigValidationTests.swift:150-156 compares against Team.xcconfig, using a #/…/# regex literal and
XCTUnwrap (spec A.3).
- scripts/validate-subscription-config.sh:113-118 reads the xcconfig; subscription-validation.yml paths gain
ios/Team.xcconfig.
- The guard checks ios/RewardE/Configuration.storekit:11 as a mirror.
- CLAUDE.md:297: the dev Firebase project is reward-e-dev.
Acceptance adds:
- [ ] ci.yml (RewardETests compiles and passes) and subscription-validation.yml are green.
- [ ] A workflow_dispatch TestFlight build succeeds with the edited Matchfile, proving the match step still loads it.
- [ ] The flip test changes Team.xcconfig and .storekit:11 together, and flipping only one fails both the guard and the
XCTest.
Est.: ~150 LOC.
A6. dashecorp/dashe-nutri-e. Title: "Read the Apple Team ID from ios/Nutri-E/Team.xcconfig; delete the legacy fastlane/ExportOptions signing"
- Sites:
project.pbxproj:315,379,411,453,491,513,534,554; workflow:13(uses:57,:168,:179;git_branch:"master"at:59; secrets:67-69,:196-197).- Guard:
team-id-guard.ymlcallsscripts/check-team-id.sh ios/Nutri-E/Team.xcconfig. Nutri-E has noci.yml, so the showBuildSettings check goes intotest.yml's iOS job.- Delete as legacy, since CI does not use them:
ios/Nutri-E/fastlane/{Appfile,Fastfile,README.md,report.xml},ios/ExportOptions.plist,ios/Nutri-E/ExportOptions.plist,ios/Nutri-E/{deploy.sh,deploy-api.sh,test-build.sh},scripts/setup-testflight-secrets.sh; dropios/Nutri-E/setup.sh:40-41.- Fix
Configuration.storekit:10from6738736024to6753125391.:11is a mirror.- Docs:
ios/Nutri-E/DEPLOYMENT.md:67,docs/testflight.md:181, and thetroubleshooting.md:110ExportOptions hint.- Delete
monitor-xcode-cloud.yml.pbxproj: add a file reference for
Team.xcconfigplusbaseConfigurationReferenceon the project-level Debug and Release configurations (:281,:345), and delete the 8 lines. showBuildSettings targets: Nutri-E, Nutri-ETests, Nutri-EUITests.Acceptance: - [ ] Same as A1. - [ ]
xcodebuild -project ios/Nutri-E/Nutri-E.xcodeproj -liststill opens. - [ ] A workflow_dispatch TestFlight upload succeeds.Est.: ~130 LOC added, ~650 removed (deletions only).
B. Nutri-E device identity across the team change (highest user impact)¶
B.1 What is stored¶
All references in this section are under nutri-e/ios/Nutri-E/Nutri-E/.
- Device ID. Keychain item
kSecClassGenericPassword, accountno.invotek.nutrie.deviceId(Services/DeviceAuthService.swift:11). - Value: a UUID, taken from
identifierForVendorat first launch, or a random UUID if IDFV is nil (:23-45). - It is lowercased on every read (
:28), not on storage. Commit bf59b1f (2025-10-09) added the read-side lowercasing but never rewrote stored items. So a device from an earlier build can still hold an uppercase item. - Accessibility:
kSecAttrAccessibleAfterFirstUnlock, not ThisDeviceOnly (:111). So it survives reinstall and restores to a new device from an encrypted backup. - No
kSecAttrAccessGroup(:100-112). It is written to the default group, which is the app IDV9GL74NMBR.no.invotek.Nutri-E. VERIFIED: "If you don't explicitly set a group, keychain services defaults to the app's first access group, which is either the first keychain access group, or the app ID when the app has no keychain groups" (kSecAttrAccessGroup). Nutri-E has no keychain groups (Nutri-E.entitlements:1-12). - Device "secret". Keychain item
no.invotek.nutrie.deviceSecret(:12). - It is
SHA-256(deviceId + salt), recomputed on every call (:50-65). The keychain copy is only a cache. - The salt is a constant compiled into the app (
:14-16; value not reproduced). The worker checks the same hash againstDEVICE_SALT(cloudflare-worker-openai/worker.js:378-384,594-605). - Classification.
- The device ID is an identifier that also works as a weak bearer credential. Anyone who knows it can compute the "secret".
- The app treats it as non-secret: it shows it and copies it to the clipboard (
Views/MoreView.swift:267,275,331), and sends it to Crashlytics as the user ID (AppDelegate.swift:21-25). - The secret needs no migration. It is derived.
- The review could not break "only the device ID needs to survive". The Cuti-E ID is in UserDefaults, Core Data is not CloudKit-backed, and the app uses the keychain for nothing else.
B.2 Who keys on it¶
| Consumer | Key | Where |
|---|---|---|
RevenueCat (live project proj2c32896f, docs/subscriptions.md:22-31) |
appUserID = device ID, set at configure |
Services/SubscriptionManager.swift:50-60 |
| Apple (StoreKit 2) | appAccountToken = device ID. RevenueCat puts it into transactions when the ID parses as a UUID, and it is frozen into the original transaction |
SubscriptionManager.swift:50-55 (comment); VERIFIED: "The App Store returns the same UUID in appAccountToken in the transaction information and subscription renewal information" (appAccountToken) |
| Nutri-E Apple webhook worker | SUBSCRIPTIONS[appAccountToken] = plan and expiry. No bundle-ID or team check |
cloudflare-worker-apple/worker.js:336-337,79-101 |
| Nutri-E Apple webhook worker (RevenueCat path) | transferSubscription(transferred_from, transferred_to) moves the record and sets a forwarding pointer. Forwarding covers writes only: SUBSCRIBED, DID_RENEW, OFFER_REDEEMED and REFUND_REVERSED on the old token land on the new ID (updateSubscription, :79-101). Apple EXPIRED, REFUND and REVOKE on the old token call removeSubscription(oldId) (:107-130, :437-454, :480-482), which does not follow forwarding. The RevenueCat CANCELLATION branch only logs (:771-773). Fixed by B3 |
worker.js:49-61,605-645,714-721; tested in test-revenuecat-transfer.mjs: the move, archive and no-op cases at :95-190, and forwarding at :277-299,301-324 |
| OpenAI worker (quota authority) | SUBSCRIPTIONS[deviceId] gives the paid tier. Without a record the device gets the free wallet wallet:v1:<id> (10 credits, +3 a week). An expired record that is still present gives FREE_DAILY_POLICY (:536-547). Also daily:<id>:<day> and DEVICE_ACTIVITY device:<id> |
worker.js:529-569,415,489-509,619,681-692 |
| DSLD worker (supplement lookups) | SUBSCRIPTIONS[deviceId] gives the daily limit: 100 for free and basic, 500 pro, 2000 premium, unlimited ultimate (cloudflare-worker-dsld/worker.js:40-70). Usage key daily:dsld:<id>:<day> (:111). The app sends the same auth headers (Services/DSLDSearchService.swift:179). It binds the same production SUBSCRIPTIONS namespace as the Apple worker (cloudflare-worker-dsld/wrangler.toml:13-14, cloudflare-worker-apple/wrangler.toml:12-13), so a TRANSFER fixes it too |
as listed |
| Crashlytics | user ID and device_id key (Release builds) |
AppDelegate.swift:21-25 |
Nutri-E stores no user content on the server. Supplements, doses and food logs are in Core Data in the app container, which is not team-bound and survives U1.
B.3 What a user loses at U1 if nothing is done¶
The keychain item is unreadable from U1 (TN2311, B.4). getDeviceId() then mints a new ID from IDFV, or from a random
UUID. Whether IDFV itself changes at a transfer is UNVERIFIED: Apple says only "Normally, the vendor is determined by data
provided by the App Store" (identifierForVendor).
| User | Effect |
|---|---|
| Free user | New ID, so a fresh free wallet of 10 credits: no loss, in fact a small gain. The support correlation (Cuti-E and Crashlytics device ID) breaks |
| Paying subscriber | Effect: RevenueCat sees a new appUserID with no entitlement, so the app shows the free tier and the paywall (SubscriptionManager.swift:187-226). The OpenAI worker serves the free wallet and the DSLD worker 100 lookups a day, because SUBSCRIPTIONS[newId] is missing. They keep being charged, and Apple renewals keep writing SUBSCRIPTIONS[oldId].Recovery routes without B2: (1) Tapping Restore Purchases ( :163-174). With the "Transfer to new App User ID" behaviour, RevenueCat sends TRANSFER and the worker moves the record.(2) The legacy syncPurchases (:83-105) runs again under the new ID on devices where it never latched. Its guard is "done, or 3 completed empty attempts" (:85-86), "done" is set only when it recovered an entitlement (:93-99), and a thrown sync counts as neither (:102-104). This covers subscribers who bought during their first two launches of 1.0.5 and have not launched since, those whose syncs all threw, and those who never ran 1.0.5. For most subscribers "done" is already set, so it does not run.(3) Possibly, a StoreKit 2 renewal that the SDK receives after U1 is posted under the new ID and triggers the same transfer. The review found that SDK 5.59.0 posts Transaction.updates with isRestore=false (PurchasesOrchestrator.swift:1424-1460). RevenueCat's backend behaviour for that post is not documented (UNVERIFIED).So B2 does not create the only automatic route. It makes recovery deterministic at the first launch |
| Everyone | Nothing on the device is lost apart from the ID |
B.4 Apple sources on keychain behaviour after a transfer¶
- TN2311, VERIFIED:
- "If the previous version of your app was writing data to the keychain, submitting an app update with the new App ID will result in a loss of access to the previous keychain data."
- "The keychain access will only be lost after an update to the app is released on the App Store."
- TN2311 offers no mitigation beyond accepting one re-entry of credentials.
- App Store Connect, VERIFIED: "Keychain sharing continues to work only until the app is updated. … After the update, users must re-login once as the app cannot retrieve the authentication token from the keychain." (Overview of app transfer)
- App-group keychain groups have no team prefix. VERIFIED:
- "You can use app group names as keychain access group names, without adding them to the Keychain access groups entitlement." (Sharing access to keychain items, under "Use app groups to expand sharing of keychain and non-keychain data")
- "App groups that you register in your Apple Developer profile also act as keychain access groups." (App Groups entitlement)
- What a query on a group the app is not entitled to returns. VERIFIED on the same Sharing page:
- "If you specify a group to which your app doesn't belong, no items match and the query returns the errSecItemNotFound status."
- For an add: "the operation fails and returns the errSecMissingEntitlement status."
- So a build that lacks the entitlement sees notFound on read, not an error (B.6).
- Apple DTS method for exactly this case (Quinn, thread 706128, "Transfer Your App to Another Team", rewritten 2026-03-31, VERIFIED Apple employee):
- "Identify an app group ID to transfer. … in many cases you'll want to register a new app group ID solely for this purpose."
- "Use the old team (the transferor) to release a version of your app that moves keychain items from other keychain access groups to the AGI keychain access group for this app group ID."
- "Give your users time to update to this new version, run it, and so move their keychain items."
- "When you're confident that the bulk of your users have done this, initiate the app transfer."
- "Once that's complete, transfer the app group ID you selected in step 1."
- "Publish an update to your app from the new team (the transferee)."
- "When a user installs this version, it will have access to your app group, and hence your keychain items."
Caveats, all VERIFIED in the same section:
- "Once you transfer the app group, the old team won't be able to publish a new version of any app that uses this app
group". Nutri-E is the only user of group.no.invotek.Nutri-E.
- Steal window: "The current transfer process of app groups exposes a small window where some other team can
'steal' your app group ID. We have a bug on file to improve that process (r. 171616887)." (B.8, B.9)
- "The process works best when transferring between two teams that are both under the control of the same entity."
That holds here: one operator controls both teams.
- "When you submit the app from the new team (step 6), App Store Connect will warn you about a potential loss of
keychain access. That warning is talking about keychain items in normal keychain access groups. Items in an AGI
keychain access group will still be accessible as long as you transfer the app group." (B.8 U1 row)
- A "Test Before You Ship" section was added 2026-04-07: test in Xcode and with a TestFlight internal build. The
post-transfer half cannot be tested before T1, so the Count-E canary (B0) plays that role.
- It is "bleeding edge" (thread 821062).
- What happens to users who never ran the step-2 build is not addressed (UNVERIFIED). For them the item stays in the
old group and is lost; B2 covers them.
B.5 Options¶
| # | Option | Identity kept? | Covers | Security | Verdict |
|---|---|---|---|---|---|
| 1 | Do nothing; rely on manual Restore and the partial automatic routes in B.3 | No | Subscribers who notice and tap Restore, plus the partial routes | unchanged | Reject: most paying users are silently downgraded |
| 2 | App-group keychain group (Apple DTS method): move the device ID into group.no.invotek.Nutri-E in an Invotek-signed build |
Yes, the same ID, so RevenueCat, the workers, Crashlytics and quota are all intact | Everyone who ran the migration build before U1 | Unchanged: still keychain, same accessibility | Recommended (primary) |
| 3 | Server-side re-binding on identity loss: the app detects the loss, calls syncPurchases() until an entitlement is found (capped), RevenueCat sends TRANSFER old→new, and the worker moves SUBSCRIPTIONS and forwards renewals (B3 makes refunds and expiries follow too) |
New ID; the subscription is re-bound on the server | Every subscriber whose App Store account holds the purchase, including those who skipped the migration build | No new storage; the proof of purchase is Apple's signed transaction through RevenueCat | Recommended (safety net) |
| 4 | New worker endpoint that verifies a StoreKit 2 transaction JWS and re-binds appAccountToken (old ID) to the new ID |
Old ID recoverable on the purchasing device | Subscribers whose purchase carries appAccountToken |
Apple-signed proof; new attack surface | Only if option 3 is unavailable (the live restore behaviour is not "Transfer") |
| 5 | Copy the device ID to UserDefaults or a file before the transfer | Yes | Everyone who ran the build | Weaker: outside the keychain, plaintext in unencrypted local backups | Reject. The ID works as a bearer credential, so keep it in the keychain and re-bind on the server. The marginal risk is small only because the app already shows the ID in the UI |
| 6 | Re-derive the ID from identifierForVendor after U1 |
Maybe | UNVERIFIED whether IDFV survives a transfer. Changing the seller name "will reset the identifierForVendor (IDFV) for existing users on their next app update" (Updating your account information), which suggests it does not. Many IDs were never IDFV anyway | — | Reject |
Recommendation: ship options 2 and 3 together in one Invotek-signed Nutri-E release, as early as possible, plus the B3 worker fix. - Option 2 keeps the identity for active users with no user action. - Option 3 limits the loss for everyone else to "new free wallet, subscription re-bound automatically". - Neither weakens storage. - The Count-E pilot proves option 2 on a real transfer before Nutri-E moves (issue B0).
B.6 Design¶
Prerequisite (operator, Invotek portal, before the code ships):
1. Register App Group group.no.invotek.Nutri-E.
2. Enable App Groups on App ID no.invotek.Nutri-E and assign the group.
3. Regenerate the match AppStore no.invotek.Nutri-E profile with generate-match-profiles, app_identifiers=no.invotek.Nutri-E.
- A capability change invalidates the old profile. Whether force:false then regenerates it is UNVERIFIED.
- The CI archive fails if the profile lacks the group, which is the check.
Code changes (B1):
Nutri-E.entitlements: addcom.apple.security.application-groups = [group.no.invotek.Nutri-E].- New
Services/KeychainStore.swift: protocol KeychainStoring { read(account:accessGroup:) -> KeychainRead; write(account:value:accessGroup:) -> OSStatus }withenum KeychainRead { case found(String), notFound, failed(OSStatus) }.readmapserrSecItemNotFoundtonotFound;SecKeychainStoreimplements it withSecItemCopyMatchingandSecItemAdd/SecItemUpdate. The access group is passed only when non-nil. Accessibility iskSecAttrAccessibleAfterFirstUnlock, the same as today.DeviceAuthService:- Inject a
KeychainStoringandUserDefaults(defaults: the real ones). static let identityAccessGroup = "group.no.invotek.Nutri-E".getDeviceId():
1. r = read(deviceIdKey, group: identityAccessGroup)
found(v) -> markIssued(); record "appGroup"; return v.lowercased()
failed(status) -> // e.g. errSecInteractionNotAllowed before first unlock (prewarm, background launch).
// NOT an entitlement miss: a query on a group the app does not belong to returns
// errSecItemNotFound, i.e. notFound (Apple, "Sharing access to keychain items").
if case .found(v) = read(deviceIdKey, group: nil) { return v.lowercased() }
record "read_error:<status>"; return a transient id (not persisted, no markers set)
notFound -> go to 2
2. legacy = read(deviceIdKey, group: nil) // no group = search all of the app's groups (the legacy default group)
found(v) -> id = v.lowercased() // builds before bf59b1f may have stored it uppercase
s = write(deviceIdKey, id, group: identityAccessGroup)
if s == errSecSuccess && read(deviceIdKey, group: identityAccessGroup) == .found(id)
record "migrated"
else
record "group_write_error:<s>" // e.g. errSecMissingEntitlement: profile lacks the group
keep the legacy item unchanged (rollback safety; it becomes unreadable at U1 anyway)
markIssued(); return id
failed(status) -> record "read_error:<status>"; return a transient id (not persisted, no markers set)
notFound -> go to 3
3. none found:
priorRun = defaults.bool("no.invotek.nutrie.deviceIdIssued") // written by this build and later
|| defaults.bool("onboardingCompleted") // written by every shipped build (Models/UserProfile.swift:109,168)
|| defaults.object("no.invotek.nutrie.revenueCatLegacySyncDone") != nil
|| defaults.object("no.invotek.nutrie.revenueCatLegacySyncAttempts") != nil
new = (IDFV ?? UUID).lowercased()
s = write(deviceIdKey, new, group: identityAccessGroup)
if s != errSecSuccess { // keep the ID stable on this build rather than minting one per launch
s2 = write(deviceIdKey, new, group: nil); record "group_write_error:<s>"
if s2 != errSecSuccess { record "write_error:<s2>"; return new } // transient: no markers set
}
markIssued()
if priorRun {
defaults.set(Date(), "no.invotek.nutrie.identityRecoveryPendingSince") // persisted: B2 reads this
record "lost"
} else { record "new" }
return new
markIssued() sets UserDefaults no.invotek.nutrie.deviceIdIssued = true. It is a non-secret marker; the
ID is never written to UserDefaults.
- The pending marker identityRecoveryPendingSince is what B2 acts on. It is persisted because the next launch
finds the newly written ID in step 1 and would otherwise never know that identity was lost. Revision 1's in-memory
identityLost flag made the promised retry impossible.
- priorRun also uses markers that older builds already wrote, because the UserDefaults in the app container
survive U1. So B2 also fires for users who skip the migration build and go straight from an old version to U1. That
is what makes option 3's coverage claim hold.
- getDeviceSecret() returns hashDeviceId(getDeviceId()) and stops writing the secret item. It is derived (B.1), so
there is nothing to migrate.
- resetDeviceCredentials() (DEBUG) deletes the item in both groups and the markers.
- Crashlytics custom key device_id_store gets
appGroup|migrated|group_write_error:<n>|new|lost|read_error:<n>|write_error:<n>. It is for support, and to measure
how many devices migrated.
Code changes (B2, identity-loss recovery):
- A
PurchasesSyncingseam overPurchases.shared.syncPurchases(), so tests can fake it. SubscriptionManager.refreshCustomer()(:66-76) callssyncIdentityRecoveryIfNeeded()right aftersyncLegacyPurchasesIfNeeded(). It mirrors the legacy sync's rules (:83-105), including its documented trap (:89-92,docs/subscriptions.md:164-169):
syncIdentityRecoveryIfNeeded():
guard defaults.object("no.invotek.nutrie.identityRecoveryPendingSince") != nil else return
guard defaults.integer("no.invotek.nutrie.identityRecoveryAttempts") < 3 else return
do {
info = try await purchases.syncPurchases()
if info.entitlements.active.isEmpty { // success with nothing found: do NOT latch
attempts += 1; record Crashlytics "identity_recovery=empty:<attempts>"; return
}
defaults.removeObject("…identityRecoveryPendingSince"); defaults.set(true, "…identityRecoverySyncDone")
updateFromCustomerInfo(info); record "identity_recovery=recovered"
} catch {
record "identity_recovery=error" // retry on the next launch; does not count as an attempt
}
syncPurchases() "reports success even when it found no transaction to post" (SubscriptionManager.swift:89-92).
The review found that in SDK 5.59.0 an empty StoreKit 2 sync returns the current CustomerInfo as success
(PurchasesOrchestrator.swift:1728-1770). So latching on any success would strand the subscriber whose App Store
account is signed out at that launch.
3. After a recovered entitlement, updateFromCustomerInfo runs refreshServerQuotaUntilTierApplies (:223-245). That
poll is bounded: 5 fetches at 0, 2, 5, 10 and 20 s (about 37 s). A TRANSFER webhook that lands later is picked up
by the next normal quota fetch.
4. Why syncPurchases and not restorePurchases: it shows no App Store sign-in prompt.
5. RevenueCat behaviour, now VERIFIED for Nutri-E's custom UUID appUserID:
- purchases-ios 5.59.0 sends syncPurchases() with isRestore: allowSharingAppStoreAccount. That property's getter
is self._allowSharingAppStoreAccount.value ?? self.currentUserProvider.currentUserIsAnonymous, which is false
for Nutri-E (PurchasesOrchestrator.swift@5.59.0).
So on the wire it is a purchase post.
- RevenueCat: "The configured behavior will also apply if an identified App User ID makes a new purchase and the
device receipt is already associated with a different identified App User ID in RevenueCat"
(Restore behavior, VERIFIED). So the transfer setting
governs the call.
- The only exception is the legacy "Share between App User IDs" mode, which "will only be available for legacy
projects with this behavior already enabled, not new projects" (same page). proj2c32896f was created in the
March 2026 split (docs/subscriptions.md:29-31).
- "The default behavior is to transfer purchases between identified App User IDs if needed" (same page, VERIFIED).
- "You can optionally set a different restore behavior for sandbox purchases" (same page, VERIFIED). So the sandbox
test in B.7 proves only the sandbox setting unless the operator confirms both are equal.
- The TRANSFER webhook "is sent only for the destination user" and carries transferred_from and transferred_to
(event types, VERIFIED). The worker
already handles exactly that shape (worker.js:714-721).
6. B2 re-binds a subscriber if four conditions hold (review):
- (a) the live project's production restore behaviour is "Transfer to new App User ID";
- (b) Dashecorp's In-App Purchase key is in RevenueCat before U1. The sync is a StoreKit 2 JWS post, and RevenueCat
cannot record it without the key. Phase 4's order (T1, then E, then U1) gives this;
- (c) the production webhook integration whintgr812efdd741 (docs/subscriptions.md:136-142) delivers TRANSFER. Its
event filter is UNVERIFIED;
- (d) the device's App Store account holds the purchase.
(a) and (c) are operator checks in E.
Code changes (B3, worker: removals follow a transfer). This is a pre-existing gap, but B2 makes TRANSFER the normal
path for every non-migrated subscriber, so it ships before Nutri-E's T0:
1. Every write stores originalTransactionId in the record:
- the Apple path from the decoded transaction;
- the RevenueCat path from original_transaction_id ("Always" present on subscription lifecycle events, VERIFIED on the
event-types page);
- transferSubscription (:605-645) copies it.
2. removeSubscription(deviceId, reason, env, { originalTransactionId }): if SUBSCRIPTIONS[deviceId] is absent and
getTransferForwarding(deviceId) (:49-61) returns targets, remove each target whose record has the same
originalTransactionId.
- A target record without the field (written before B3) is removed too, and the worker logs
forwarded_removal_unkeyed.
- A target with a different originalTransactionId is a separate, newer purchase. It is never removed.
3. RevenueCat CANCELLATION with cancel_reason == "CUSTOMER_SUPPORT" removes the record.
- RevenueCat's definition: "Customer received a refund from Apple support" (VERIFIED, event-types page).
- Other cancel reasons keep today's log-only behaviour (:771-773), since the subscription stays active until expiry.
4. It deploys to the v3 workers only. Apple's notifications reach it only after the App Store Server Notifications URL
is repointed from v2 to v3 (Order of work, Phase 0 step 6); v2 is frozen.
Count-E canary (B0). It proves the app-group keychain method on the real pilot transfer, and it is the post-transfer
half of Quinn's "Test Before You Ship", which cannot run before T1.
- Count-E already has group.no.invotek.CountE (CountE.entitlements:9), so it needs no new capability.
- On launch, write a random UUID once. Keychain account no.invotek.CountE.keychainCanary, access group
group.no.invotek.CountE, AfterFirstUnlock. Also set UserDefaults keychainCanaryWritten=true.
- Settings > About shows "Keychain continuity: present / missing / not written" (accessibility id
settings-keychain-canary).
- After Count-E's U1, a device that ran the canary build before T1 must show present.
B.7 Tests to write first¶
Unit tests in Nutri-ETests use a fake KeychainStoring that models access groups:
- items are keyed by (group, account), and read(group: nil) searches all visible groups;
- a group the app is not entitled to reads as notFound, and writes to it return errSecMissingEntitlement, as Apple
documents.
Simulator unit tests run without the entitlement, so real app-group writes fail there.
- Legacy item only →
getDeviceId()returns it, the group now holds the same value, the legacy item is still there, anddeviceIdIssuedis set. - Legacy item stored uppercase → the returned ID and the group copy are lowercase.
- Group item present → returned lowercase, no writes.
- Simulated U1: hide the legacy group, as the new team would. The group item is still visible → same ID. This is the case the migration protects.
- Simulated U1 without migration:
- Hide the legacy group, with no group item and
deviceIdIssuedset → a new ID, andidentityRecoveryPendingSinceis persisted. - Repeat with only
onboardingCompleted=true(a user who never ran the migration build): the same result. - Relaunch (a new service instance): the new ID is returned, and the pending marker is still set.
- Fresh install, no markers → a new ID and no pending marker.
readreturnsfailed(errSecInteractionNotAllowed)→ the app does not write a new ID and returns a transient one. Today's code would mint and overwrite.- Entitlement missing (the group reads notFound, and group writes return
errSecMissingEntitlement): - with a legacy item: the legacy ID is returned and
device_id_store=group_write_error:-34018is recorded; - with no item: the ID is written to the default group, the next launch returns the same ID, and no pending marker is set on a fresh install.
getDeviceSecret()equals the hash ofgetDeviceId()and performs no keychain write.- B2, with a fake
PurchasesSyncing:- never called without the pending marker;
- called once per launch while pending;
- an empty success increments attempts and keeps the marker, and stops after 3;
- a throw keeps the marker and does not count;
- a success with an active entitlement clears the marker and sets
identityRecoverySyncDone, and no later launch calls it.
- B3 worker tests, in
cloudflare-worker-apple/test-revenuecat-transfer.mjsstyle:- TRANSFER A→B, then an Apple REFUND on token A with the same
originalTransactionId→SUBSCRIPTIONS[B]is removed; - TRANSFER A→B, then a RevenueCat INITIAL_PURCHASE on B with another
originalTransactionId, then an Apple EXPIRED on A → B is kept; - RevenueCat CANCELLATION with
cancel_reason=CUSTOMER_SUPPORT→ removed; withUNSUBSCRIBE→ kept; - the existing forwarding tests (
:277-324) stay green.
- TRANSFER A→B, then an Apple REFUND on token A with the same
- Device test on hardware (manual checklist in the PR):
- Install the current App Store build.
- Install the TestFlight migration build and launch it: the Crashlytics key
device_id_store=migrated, and the ID in More > Device ID is unchanged. - Relaunch:
appGroup.
- Sandbox end-to-end test of B2 (TestFlight, sandbox Apple ID):
- Operator first confirms that the sandbox restore-behaviour override is off, or set to the same value as production,
and that the sandbox webhook integration
whintgr46e9d8efa9delivers TRANSFER. - Buy
no.invotek.nutrie.basic_monthly. - In a DEBUG build, delete only the app-group and legacy items but keep
deviceIdIssued. - Relaunch: a new ID,
syncPurchases, then a TRANSFER event in the RevenueCat dashboard,SUBSCRIPTIONS[newId]in the sandbox KV, and the tier restored in the app with no user action. - This confirms the chain end to end. The SDK and doc evidence (B.6 B2.5) already settles that
syncPurchasesfollows the setting. It does not prove the production setting, which is an operator check (E).
- Operator first confirms that the sandbox restore-behaviour override is off, or set to the same value as production,
and that the sandbox webhook integration
B.8 Before, at and after¶
| When | Step |
|---|---|
| Now | Operator registers the group (B.6 prerequisite). B1, B2 and B3 merged; the ASSN URL repointed to v3; Nutri-E released (Invotek-signed). Count-E canary (B0) released |
| Before T0 (Nutri-E) | Gate: at least 90% of 30-day active devices on the migration build or later (ASC App Analytics, operator), and Count-E canary = present after the pilot's U1. Proposed gate values; see Open questions |
| T1 | One operator session: Invotek deletes group.no.invotek.Nutri-E, Dashecorp registers it immediately, enables it on no.invotek.Nutri-E and adds it to the Dashecorp App Store profile. Then confirm it in Dashecorp's Identifiers list before NB1 is built (steal window, B.4) |
| U1 | The first Dashecorp build keeps the same entitlement. At submission, App Store Connect warns about a possible keychain loss. That is expected for AGI items (B.4) and is not a reason to abort. Migrated users read their ID. Others hit B2 |
| After | Watch the Crashlytics device_id_store and identity_recovery distributions and the TRANSFER events. Support path: "Restore Purchases" in More |
B.9 Risks¶
- The DTS method is "bleeding edge". The Count-E canary and the optional two-team dev spike (below) reduce this. B2 is the fallback.
- Another team registers
group.no.invotek.Nutri-Ein the gap between delete and register (r. 171616887). Then U1 cannot carry the group, and every user falls to B2. Mitigated by the one-session move (B.8 T1). If it happens, do not release U1 with a different group until the operator decides: every migrated item would be lost. - The App Group cannot be deleted at Invotek, or not registered at Dashecorp. Then U1 cannot carry the entitlement. Order: delete first, then register. Test it on Count-E.
- The live project's production restore behaviour is not "Transfer to new App User ID", or TRANSFER is filtered out of
whintgr812efdd741. Then B2 recovers nothing and option 4 is needed. Operator checks (E). - The Dashecorp In-App Purchase key is not in RevenueCat before U1. Then B2's post cannot be recorded. Phase 4's order prevents it.
syncPurchasescan move a subscription away from another device that shares the same Apple ID and a different Nutri-E ID. That is how "Transfer" works, and the legacy sync and Restore already have the same effect. It is limited to capped attempts after an identity loss.- Adding App Groups to Nutri-E needs a profile regeneration. A wrong match run could create a certificate; A4's safety model covers it.
- Pre-existing: the salt is in the binary (B.1). Do not fix it in the same release. Changing the auth scheme during the migration would confound both.
B.10 Issue drafts¶
B0. Repo dashecorp/dashe-count-e. Title: "Keychain continuity canary in the App Group (proves the transfer method before Nutri-E)"
Count-E is the transfer pilot. Before Nutri-E relies on Apple DTS's app-group keychain method (thread 706128), prove it on Count-E's real transfer. This is also the post-transfer half of Quinn's "Test Before You Ship".
- Add
KeychainContinuityCanary:- account
no.invotek.CountE.keychainCanary, access groupgroup.no.invotek.CountE,kSecAttrAccessibleAfterFirstUnlock;- writes a random UUID once and sets UserDefaults
keychainCanaryWritten;- exposes
status: notWritten | present | missing | error(OSStatus).- Show it in Settings > About with accessibility id
settings-keychain-canary.Tests first: unit tests with a fake keychain for each status transition (a group the app is not entitled to reads as notFound), plus a UI test that the row exists.
Acceptance: - [ ] Released on the App Store under Invotek before Count-E's T0. - [ ] Runbook note: after Count-E's U1, 2 devices that ran this build show
present. Record the result in claude-3#954.Est.: ~120 LOC including tests.
B1. Repo dashecorp/dashe-nutri-e. Title: "Move the device ID into the App Group keychain group before the App Transfer"
Why:
DeviceAuthServicekeeps the device ID in the default keychain group (DeviceAuthService.swift:96-138), which is prefixed with Team ID V9GL74NMBR. It becomes unreadable at the first Dashecorp-signed update (TN2311). The ID is the RevenueCatappUserID, the StoreKitappAccountTokenand the key forSUBSCRIPTIONSand quota in the OpenAI and DSLD workers. This implements Apple DTS's app-group method (forums thread 706128, steps 1-3).Prerequisite (operator): the
group.no.invotek.Nutri-EApp Group is registered in team V9GL74NMBR, enabled on App IDno.invotek.Nutri-E, and the App Store profile is regenerated.Do: spec B.6 (B1): - entitlement; -
KeychainStoringplusSecKeychainStore(errSecItemNotFoundmaps to notFound); - the newgetDeviceId()algorithm, which lowercases the migrated legacy ID and falls back to the default group when the group write fails; -getDeviceSecret()becomes derive-only; - thedeviceIdIssuedmarker, thepriorRundetection (which also readsonboardingCompletedand the RevenueCat legacy-sync keys that older builds wrote), and the persistedidentityRecoveryPendingSincemarker; - the Crashlyticsdevice_id_storekey.Keep the account name, the lowercase normalisation and
AfterFirstUnlock.Tests first: spec B.7 items 1-9, plus the device checklist in item 12.
Acceptance: - [ ] All B.7 unit tests pass in CI. - [ ] The device checklist is done on 1 device, and its result is written in the PR. - [ ] The archive in
build-and-upload.ymlsucceeds with the new entitlement. - [ ] No device ID is ever written to UserDefaults (grep test). - [ ]docs/gets a dated design note;CLAUDE.md"Device Authentication" is updated.Est.: ~340 LOC including tests.
B2. Repo dashecorp/dashe-nutri-e. Title: "Re-bind the subscription automatically when the device identity is lost"
Why: users who do not run the B1 build before the first Dashecorp-signed update get a new device ID. Their subscription must follow without a manual Restore.
syncPurchases, then RevenueCat TRANSFER, then the worker'stransferSubscription(cloudflare-worker-apple/worker.js:605-645, tested) re-binds it on the server.Do: spec B.6 (B2): - the
PurchasesSyncingseam; -syncIdentityRecoveryIfNeeded(), gated on the persistedidentityRecoveryPendingSincemarker from B1; - it latches (clears the marker and setsno.invotek.nutrie.identityRecoverySyncDone) only whenentitlements.activeis non-empty; - an empty success counts towardidentityRecoveryAttempts(cap 3), and a throw retries next launch without counting, the same rule as the legacy sync; - Crashlyticsidentity_recovery.Tests first: spec B.7 item 10, then the sandbox end-to-end test in item 13, with its result in the PR.
Acceptance: - [ ] Unit tests pass. - [ ] The sandbox end-to-end run shows a TRANSFER event,
SUBSCRIPTIONS[newId]in the sandbox KV, and the paid tier restored with no taps. The operator confirmed the sandbox override beforehand. - [ ] Operator has confirmed that the production restore behaviour ofproj2c32896fis "Transfer to new App User ID", and thatwhintgr812efdd741delivers TRANSFER.Ships in the same release as B1. Depends on: B1. Est.: ~170 LOC including tests.
B3. Repo dashecorp/dashe-nutri-e. Title: "Apple webhook worker: refunds and expiries follow a RevenueCat transfer"
Why:
transferSubscriptionsets a forwarding pointer that only writes follow (updateSubscription,cloudflare-worker-apple/worker.js:79-101). - Apple EXPIRED, REFUND and REVOKE on a transferred-outappAccountTokencallremoveSubscription(oldId)(:107-130,437-454,480-482). The record is already gone there, so the new ID keeps paid access untilexpiresAt. After that the OpenAI worker givesFREE_DAILY_POLICYinstead of the wallet (cloudflare-worker-openai/worker.js:536-547). - The RevenueCat CANCELLATION branch only logs (:771-773), so a RevenueCat-reported refund is ignored too. - Identity-loss recovery (B2) makes TRANSFER the normal path for every non-migrated subscriber at the team change.Do: spec B.6 (B3): - store
originalTransactionIdon every write; -removeSubscriptionfollows forwarding for targets with the sameoriginalTransactionId, never for a different one, and logs unkeyed legacy targets; - RevenueCatCANCELLATIONwithcancel_reason=CUSTOMER_SUPPORTremoves the record.Tests first: spec B.7 item 11.
Acceptance: - [ ]
test-revenuecat-transfer.mjsand the new tests pass in CI. - [ ] Deployed to sandbox-v3 and v3 bydeploy-workers.yml. - [ ] v2 is untouched.Depends on: the ASSN URL repoint to v3 (Order of work, Phase 0). Est.: ~120 LOC including tests.
B-spike (optional; needs NEW_TEAM). Repo: a scratch repo dashecorp/keychain-team-spike, not a product repo. Title: "Two-team App Group keychain spike"
Gives an earlier signal than the Count-E pilot. 1. Invotek registers
group.no.invotek.kcspike. 2. A dev-signed Invotek app writes a keychain item into that group. 3. In one session, Invotek deletes the group and Dashecorp registers it. 4. A dev-signed Dashecorp app (another bundle ID) with the group reads the item on the same device.Pass means the item is read.
Est.: ~100 LOC, throwaway.
C. Cuti-E SDK and backend (ios-sdk, dashe-cutie)¶
C.1 Findings¶
Paths in this table are under ios-sdk/Sources/CutiE/ for the SDK and dashe-cutie/cloudflare-worker/src/ for the
backend.
| Item | Where | Team-bound? | At T1 / U1 | Change |
|---|---|---|---|---|
SDK device ID com.cutie.deviceID |
UserDefaults, CutiE.swift:30-39 |
No | Survives both | none |
SDK device token com.cutie.deviceToken |
Keychain, default group, ThisDeviceOnly, CutiEAPIClient.swift:37-79 |
Yes (keychain) | Lost at U1. ensureDeviceToken() re-registers (:697-717). The server returns the same token for (customer, device ID) (handlers/device-tokens.js:75-99), so conversations, which are keyed by device ID, stay intact |
none |
SDK App Attest key ID com.cutie.appAttest.keyId |
Keychain, CutiEAppAttest.swift:70-79,345-390 |
Yes | Unused: no dashe app sets useAppAttest: true, and the default is false (CutiE.swift:57 on main, :56 in the shipped 2.3.1; :84-90). Call sites: Nutri-E CutiEService.swift:37, Reward-E :26, Count-E :32, Fast-E :32, Drink-E :24, Heart-E :24 |
none |
| Backend App Attest verification | handlers/app-attest.js:339-372,383-418 |
Would be (RP ID) | verifyAttestation rejects blobs under 100 bytes and otherwise accepts (:345-366). verifyAssertion requires only 50 bytes (:390), then increments the sign count and accepts (:405-411). Neither checks the RP ID or the signature |
none now. Constraint: any future real verification must accept both V9GL74NMBR.<bundle> and NEW_TEAM.<bundle> during the migration |
| Backend DeviceCheck | handlers/device-check.js:16-18,56,343-348 (APPLE_TEAM_ID, APPLE_DEVICECHECK_KEY_ID, APPLE_DEVICECHECK_PRIVATE_KEY) |
Yes | The public SDK has no DeviceCheck client code in any version. It exists only in the stale dashe-cutie/ios/Sources/CutiE/CutiEDeviceCheck.swift, and nothing calls /v1/device/check (review). The dashe apps resolve SDK 2.3.x (Package.resolved in fast-e, drink-e, heart-e, reward-e; nutri-e requires ≥ 2.3.0 at project.pbxproj:613-616). Do not run ios/release-public.sh:34-35, which would publish the stale copy |
none. Operator confirms zero traffic on /v1/device/check/* |
| APNs per app | Cloudflare D1 table apps: apns_key_id, apns_team_id, apns_auth_key, bundle_id, apns_sandbox; utils/notifications/apns.js:20-41 |
Yes | Fails from T1 (VERIFIED, DTS, 0.2) for every transferred app with an APNs row. Which apps have rows, and that they share Invotek key 635XA3L42Q, comes from the fact base and ios-app-transfer.md:415-416, not from code: UNVERIFIED |
config PATCH at T1. The operator confirms the rows first (C.3.0) |
| APNs customer-level fallback | customers table, apns.js:50-80; written by handlers/customers.js:152-160 |
Yes | Used if an app row is incomplete. It would silently sign with a possibly Invotek key | validation (C1), for both handlers |
| APNs env fallback | apns.js:83-92; Feedback App queue.js:650-658,719-726 |
Yes | Serves Cuti-E Admin and the Feedback App (no.invotek.Cuti-E-Feedback), which stay with Invotek for now |
do not change at dashe cutovers |
| Invalid-token handling | apns.js:228-243 deactivates only on 410, BadDeviceToken and Unregistered |
— | A wrong-team 403 does not wipe tokens, so the T1 gap does not lose push registrations | pin with a test (C1) |
| Test push | POST /v1/admin/notifications/test (index.js:1152, handlers/notifications.js:365-509) picks the most recent active token of any SDK user for the app (:401-407). Its sender builds the endpoint URL itself (:555-565) |
— | A verification push at T1 would go to a real user | add a device_id target and the port (C1) |
| Connection per team | apns.js:210-219 (fetch per push to api.push.apple.com / api.sandbox.push.apple.com); wrangler.toml:3-4 (compatibility_date = "2024-01-01", flags nodejs_compat only) |
Yes, after Fast-E's T1 | Apple: "You can't map a connection to APNs to multiple teams … open separate connection pools for each of them" (VERIFIED). From Fast-E's T1 the worker signs for two teams through one origin. Whether Cloudflare's fetch pooling mixes them is UNVERIFIED |
per-team port, built in C1 (C.2) |
| Cert pinning | CutiECertificatePinning.swift:49-53: api.cuti-e.com, cutie-worker-sandbox.invotekas.workers.dev, invotekas.workers.dev |
No (Cloudflare) | — | phase 2, out of scope |
Apps and push (from the fact base; the operator confirms in C.3.0):
- Transferred, with APNs rows: Nutri-E, Fast-E, Reward-E.
- Count-E: no APNs config.
- Drink-E: uses 635XA3L42Q, but stays with Invotek until it is recreated as com.dashecorp.* with a new Cuti-E app row.
- Heart-E: not registered in Cuti-E.
C.2 What must change¶
- SDK (shipped before): nothing is required.
- Optional hardening, not needed for the transfer: on
DCError.invalidKeyfromgenerateAssertionorattestKey, callreset()and re-attest once. TodayisAttestedis assumed whenever a key ID exists (CutiEAppAttest.swift:121-127). - It matters only if an app ever enables App Attest. VERIFIED: attested keys "don't survive app reinstallation, device migration, or restoration of a device from a backup" (Establishing your app's integrity).
- Backend code (C1, before Fast-E's T0, the first transferred app with APNs):
PATCH /v1/apps/:appId(handlers/apps.js:251-325): no half-changed credentials, and routine saves still work.- Revision 1's "all three or none" rule would break both admin clients.
- The web dashboard (
admin-dashboard/js/settings.js:612-620) sends the prefilledapns_key_idandapns_team_idon every save of an iOS app, andapns_auth_keyonly "if new key provided". - The Android admin app does the same (
AppDetailScreen.kt:550-552,AppDetailViewModel.kt:133-135). - A rename, an icon change or a brand-colour change would then return 400.
- The iOS admin app sends only changed fields (
ios-admin-app/.../Views/SettingsView.swift:2273-2275).
- The web dashboard (
- The rule, evaluated against the stored row:
- A field changes when the body carries a non-empty
apns_key_idorapns_team_idthat differs from the stored value. If either changes, the same request must carry a non-emptyapns_auth_key. The resulting key ID and team ID must both be non-empty. Otherwise return 400apns_credentials_incomplete, and the row is unchanged. - Unchanged
apns_key_idandapns_team_idsent without an auth key → accepted. This is the routine save. apns_auth_keyalone (a re-upload for the same key ID) → accepted.- Clearing: all three null or empty in one request clears them. Clearing only some of them → 400.
- The format check
^[A-Z0-9]{10}$applies to changed key ID and team ID values only. Unchanged legacy values stay editable. - The auth key must be a PKCS#8 PEM that
crypto.subtle.importKey('pkcs8', …)accepts, asapns.js:125-141does.
- A field changes when the body carries a non-empty
- The existing single
UPDATEstays atomic (apps.js:404-407). - Apply the same rule to
PATCH /v1/customer(handlers/customers.js:152-160), which feeds the fallback.
- Revision 1's "all three or none" rule would break both admin clients.
POST /v1/admin/notifications/test: accept an optionaldevice_id(the SDKX-Device-ID,device_<UUID>) and use only that device's active token forapp_id. Without it, keep today's behaviour but return a warning field.- Per-team APNs connections. Add an optional per-app
apns_portcolumn (443 or 2197, default 443).sendAPNs(apns.js:210-219) andsendAPNsTest(notifications.js:555-565) buildhttps://<host>:<port>/3/device/<token>.- VERIFIED: "You can also use port 2197 (instead of port 443) on either server when communicating with APNs" (Sending notification requests).
- Add
allow_custom_portstocompatibility_flagsinwrangler.toml:4, for every environment. Do not bump the whole compatibility date. Without the flag, Workers ignore a custom port and connect to 443, so the column would silently do nothing. The flag became the default only from compatibility date 2024-09-02 (Cloudflare compatibility-flags doc, review; developers.cloudflare.com was outside this revision's fetch allow-list). - Use it from Fast-E's T1:
NEW_TEAMrows getapns_port=2197, and Invotek rows (Cuti-E Admin, Drink-E) stay on 443. That gives each team its own origin. Whether Cloudflare pools by origin including the port is UNVERIFIED; the Fast-E cutover checks delivery for both teams (C.3). - Revision 1 planned this only as a contingency. Apple requires separate pools, so it is planned from the start.
- Tests: a 403
InvalidProviderTokenresponse from APNs does not deactivate the token (C.4). - Document the global
APNS_*env (C.1) in code comments. - Backend config (at T1 per app, operator, secret-bearing):
- PATCH the app row with Dashecorp's APNs key ID,
apns_team_id=NEW_TEAM, the.p8contents andapns_port=2197, in one request. - Then force fresh APNs connections. Apple: "For transferred apps, close and recreate any existing connections"
(VERIFIED). The worker uses
fetchper push and cannot close pooled connections itself.- A redeploy of
cutie-workerrecycles isolates: re-run the deploy workflow, orwrangler deploy --env production. - Whether that also drops Cloudflare's upstream pooled HTTP/2 connections is UNVERIFIED. The port change gives the new team a new origin, which does not depend on it.
- A redeploy of
- Keep the global
APNS_*env and the Invotek key for Cuti-E Admin, the Feedback App and Drink-E. - The Dashecorp APNs key is a team-scoped Production key.
- Apple's current APNs page: team-scoped keys "restrict usage to either Sandbox or Production", and existing dual-environment keys "will continue to be supported" (VERIFIED, APNs token auth page).
- The Account help page still says a key "works for both the development and production environments" (review). The two pages disagree, so do not rely on a dual key.
- Each Cuti-E app row holds one key plus the
apns_sandboxflag, and App Store and TestFlight builds use production. Add a Sandbox key only if dev-build testing needs it. dashe-cutie/CLAUDE.mdstill tells the reader to pick "Sandbox & Production". Update it in C1.- Existing device tokens keep working.
- A token belongs to the app on the device, and Apple tells the recipient to "update your APNs servers with the chosen key" (VERIFIED).
- DTS says device tokens do not change because of a transfer (review). Apple's token docs list only restore, a new device and an OS reinstall.
- The SDK re-registers the token whenever the app passes it on (
CutiEPushNotifications.swift:128-151).
C.3 Order¶
- Confirm the rows (operator, before Fast-E's T0). List the
appsrows' non-secret columns only:app_id,bundle_id,apns_key_id,apns_team_id,apns_sandbox. Never selectapns_auth_key. This settles which apps need a PATCH. - Merge and deploy C1, including
allow_custom_ports, before Fast-E's T0. - When
NEW_TEAMexists: operator creates one Dashecorp team-scoped Production APNs key and stores it in Bitwarden. - Per app at T1:
- PATCH the row (with
apns_port=2197). - Redeploy the worker.
- Operator: on a test device with notifications allowed, open the app (still the Invotek build) and send feedback.
Read the conversation's full
device_idfromGET /v1/conversations/:id, which returnsc.*(handlers/conversations.js:536), or from the Cuti-E MCPget_conversation. The admin dashboard shows only the last 6 characters (admin-dashboard/js/conversation.js:200). POST /v1/admin/notifications/test {app_id, device_id}. It must be delivered. The worker log must show the new key ID and port 2197. A delivery over an old pooled connection proves nothing (0.2), which is why this runs after the redeploy and checks the port.- An admin reply to a real conversation must be delivered.
- Fast-E only (the first mixed-team day): also send a test push to Cuti-E Admin or Drink-E (Invotek, port 443). Both teams must deliver.
- At U1: nothing on the Cuti-E side. The SDK token re-registers silently.
C.4 Tests first (dashe-cutie, Vitest, test/handlers/apps.test.js and notifications.test.js)¶
- PATCH that changes only
apns_team_id, without an auth key → 400, and the row is unchanged. - PATCH that changes the key ID and team ID with an auth key → 200, and all three are stored.
- PATCH with the stored key ID and team ID and no auth key (the dashboard's routine save) → 200, and nothing is cleared.
- PATCH with all three null → cleared. PATCH that clears only one → 400.
- Changed team ID with an invalid format → 400.
- Rewrite the existing
apps.test.js:532-552("should update APNs settings"). It sends key IDABC123and team IDTEAM123without an auth key to an app with no stored values, so it fails both new rules. Use valid 10-character values and include an auth key generated in the test. - The same rules for
PATCH /v1/customer. - A test push with
device_idsends to that device's token only. A mockedfetchchecks the path/3/device/<token>. apns_port=2197→ the mockedfetchURL ishttps://api.push.apple.com:2197/3/device/<token>, for bothsendAPNsandsendAPNsTest. The unit test proves the URL only; the Fast-E check in C.3 proves that the runtime honours it.sendAPNswith a mocked 403{"reason":"InvalidProviderToken"}→shouldDeactivateis false. Optionally also pin 400TopicDisallowedandDeviceTokenNotForTopicas not deactivating.
C.5 Risks¶
- Push gap between T1 and the PATCH. Keep it to minutes: the key exists in advance, and the operator does the PATCH right after the transfer completes.
- Mixed-team connection reuse inside Cloudflare. Apple requires separate pools (VERIFIED). Whether the per-team port achieves that on Cloudflare is UNVERIFIED. It is detected at the Fast-E cutover (C.3.3.6).
- The flag is missing in one environment and the port is silently ignored. C1 sets it at the top level and in every
[env.*]block, and the C.3 port check in the log catches a miss. - A future "real" App Attest verification that pins one App ID would break old or new builds. Note it in
app-attest.js. - The later Dashecorp recreation of Cuti-E Admin and the Feedback App needs the env
APNS_*changed then (0.3.5).
C.6 Issue drafts¶
C1. Repo dashecorp/dashe-cutie. Title: "APNs credentials: safe per-app update, per-team port and targeted test push (iOS team-change prep)"
Why: Nutri-E, Fast-E and Reward-E move from Apple team V9GL74NMBR to Dashecorp. At each app's transfer completion, its
appsrow needs a new APNs key ID, team ID and.p8in one step, verified without pushing to a random user. From Fast-E's T1 the worker signs for two teams, and Apple requires separate connection pools per team.Do: -
handlers/apps.js:273-325andhandlers/customers.js:152-160: when the key ID or team ID changes, the auth key is required in the same request; unchanged values without an auth key are accepted (the dashboard and Android save contract); clearing is all-or-nothing; format checks on changed values (spec C.2.2.1). - Migration:apps.apns_port INTEGER DEFAULT 443;sendAPNsandsendAPNsTestuse it;wrangler.tomlgainsallow_custom_portsincompatibility_flagsfor every environment. -handlers/notifications.js:365-509: optionaldevice_idtarget; add a warning when it is missing. - A test pinning that 403InvalidProviderTokennever deactivates tokens (utils/notifications/apns.js:228-243). - A code comment inutils/notifications/apns.js:83andqueue.js:650: the envAPNS_*serves Invotek's Cuti-E Admin and Feedback App; do not repoint it at a dashe cutover. - UpdateCLAUDE.md"Push Notifications": team-scoped keys are Sandbox-only or Production-only. Update the admin API doc.Tests first: spec C.4 1-10.
Acceptance: - [ ] Vitest is green, with the new tests failing before the change. - [ ] A rename of a configured iOS app in the web dashboard still saves (manual check on sandbox). - [ ] Deployed to sandbox. - [ ] A dry run: PATCH a sandbox app with all three fields and
apns_port=2197, then a targeted test push to a sandbox device is delivered.Est.: ~220 LOC including tests.
No ios-sdk issue is needed (C.2.1).
D. Reward-E: Sign in with Apple via Firebase, App Check, APNs, StoreKit¶
D.1 Findings¶
- Sign in with Apple is native.
ASAuthorizationAppleIDProviderrequests.fullName, .emailwith a nonce (reward-e/ios/RewardE/Services/AuthService.swift:93-110).- The token is passed to Firebase through
OAuthProvider.appleCredential(withIDToken:rawNonce:fullName:)andAuth.auth().signIn(with:)(:191-228). - Android uses Google sign-in (
android/.../OnboardingScreen.kt:21,61), andweb/has no Firebase Auth. So nothing here uses Apple's web or code flow (VERIFIED by grep). The app never callsgetCredentialState(review). - Server-side data is keyed by the Firebase UID:
- Firestore members
{uid}anduserId(firestore.rules:14-19,138-142); subscription.ownerId(:219-220);- the RevenueCat
appUserID, throughlogIn(uid)(Services/SubscriptionService.swift:320-335,Models/AppState.swift:228,369). - Local Core Data is not keyed by the UID.
CDFamily.ownerId(Services/Storage/LocalStorageService.swift:27,46) holdslocalUserId, alocal_<UUID>kept in UserDefaults (Services/Storage/StorageManager.swift:24-31). Core Data backs only the free tier (:47-62,:73-85). Paid-tier data is in Firestore.
So if the UID is preserved, nothing in Firestore or RevenueCat needs rewriting, and Core Data never depends on the
UID. The only thing to change is the apple.com provider link on each Firebase Auth user: old sub to new sub,
plus the relay email.
3. Without migration:
- From T1, Apple returns a new sub (VERIFIED, 0.2).
- Firebase finds no user for that sub, so it creates a new UID (or raises an account-exists error when the email
matches). Which of the two happens is UNVERIFIED.
- The user sees an empty app, and the family data is orphaned. docs/troubleshooting.md:467 describes the same orphaning
after an account deletion.
4. Forced re-sign-in at U1 (VERIFIED from the SDK source):
- Firebase Auth 10.29.0 (the version Package.resolved pins) saves the signed-in user through
FIRAuthKeychainServices. Its query sets only kSecClass, kSecAttrAccount and kSecAttrService, with no
kSecAttrAccessGroup (FIRAuthKeychainServices.m@10.29.0;
the review also cites FIRAuth.m@10.29.0:2358-2386). No access group is used unless useUserAccessGroup is set,
and Reward-E sets none (grep).
- RewardE.entitlements has no keychain-access-groups, only an app group. So the default group is the App ID
(kSecAttrAccessGroup, B.1), which carries the old prefix.
- No Firebase doc page states the default; the SDK source is the primary evidence.
- Effect: on a device's first run of a NEW_TEAM-signed build, authService.currentUser is nil. AppState.swift:179-190
then takes the no-session path, and a paid user sees "Sign in to continue" (OnboardingView.swift:230-245, review).
- Free-tier users need no Firebase session ("Free tier: no auth needed", AppState.swift:185-187), and Android users are
unaffected.
- By then their sub is the Dashecorp one. So the re-link must be complete before U1 ships, and before any
external TestFlight tester runs NB1.
- A possible mitigation, UNVERIFIED and not recommended (D.7): Firebase documents moving the current user into a shared
group with useUserAccessGroup followed by updateCurrentUser. Calling useUserAccessGroup alone signs the user out
(FIRAuth.m@10.29.0:2567-2590, review).
5. App Check:
- Release builds use AppAttestProvider (Services/AppCheckService.swift:40-51,63-69, Release at :47-49). The
DeviceCheck fallback runs only below iOS 14; the target is 16.0 (ios/project.yml:5), so it is dead code.
- Firebase: "enter the Team ID of your Apple Developer Account" for App Attest
(App Attest codelab, VERIFIED).
- The setting is the Firebase iOS app's single teamId (IosApp.teamId in the Firebase Management API, review). No
setting accepts both teams at once.
- Whether App Check enforcement is on is UNVERIFIED (operator). It can only matter for Firestore. Reward-E's
functions are all onDocumentCreated triggers (functions/src/index.ts:9,39,103,137,174), with no callable or HTTP
function. Cloud Functions enforcement is set in code (onCall({enforceAppCheck: true})), not in the console.
6. FCM is not used. The only Firebase products are Auth, Firestore, Crashlytics and AppCheck (ios/project.yml:125-132).
Pushes go through Cuti-E (item C). No Firebase APNs key is on any path.
7. Build and backend:
- Firebase projects: reward-e-app (prod) and reward-e-dev (dev). Sources: .firebaserc,
ios/RewardE/GoogleService-Info-Dev.plist:14, android/app/src/debug/google-services.json:4, README.md:61,
ios/project.yml:146. CLAUDE.md:297 is stale.
- Cloud Functions deploy through Workload Identity Federation (.github/workflows/deploy-functions.yml:23-27,51).
- GoogleService-Info.plist is keyed by bundle ID, so it is unchanged.
D.2 Apple's procedure (VERIFIED)¶
Sources:
- [T] Transferring your apps and users to another team
- [B] Bringing new apps and users into your team
- [TN] TN3159
- [CS] Creating a client secret
is the JWT format reference. Its kid is described as the key "generated for the Account and Organizational Data
Sharing private key" (VERIFIED). For Sign in with Apple the kid is the Sign in with Apple key ID. That comes from
Apple's Sign in with Apple token-generation page (review; this revision could not resolve that page's DocC URL).
- Sender token [T]:
client_idis "The identifier (App ID or Services ID) for the transferring app. The identifier must not include your Team ID".- Client secret JWT:
- header
alg: ES256,kid: <Sign in with Apple key ID>; - claims
iss: V9GL74NMBR,iat,exp(at most15777000s ahead),aud: https://appleid.apple.com,sub: no.invotek.RewardE("case-sensitive").
- header
- The example response has
expires_in: 3600. "There is also no rate limiting for this endpoint" [TN]. - Generate a transfer ID per user (sender) [T]: Apple: "you must exclude the team-scoped identifier and private email address, and include only the transfer identifier" when exporting to the recipient. See D.3.3 for how this design deviates.
- Exchange (recipient) [B]:
Apple: "This value is the same as the
POST https://appleid.apple.com/auth/usermigrationinfo Authorization: Bearer <recipient access_token> transfer_sub=<transfer_sub>&client_id=no.invotek.RewardE&client_secret=<JWT signed by NEW_TEAM's key, iss=NEW_TEAM> → {"sub": "<new sub>", "email": "<new relay address, only if private>", "is_private_email": true}subin the ID token issued during user sign-in." - Windows. Apple anchors the 60 days in two places. This spec uses the earlier one, acceptance (TA).
- [T], VERIFIED: "Your team can generate transfer identifiers before the recipient team accepts the app transfer, or up to 60 days after the recipient team accepts the app transfer."
- [T], VERIFIED: "The recipient team needs to exchange the transfer identifiers for new team-scoped identifiers within 60 days of accepting the app transfer."
- [TN] counts from completion (T1): "you have a sixty (60) days transfer period from the date you complete the app transfer". DTS forum 764888 does too (review). Processing takes up to 2 business days, so TA can be up to 4 calendar days before T1.
- ID tokens carry
transfer_sub"for up to 60 days from the transfer date"; after that "the server endpoint … becomes inactive" [B]. - Missed window: "transfer the app back to the initial team and back" [TN].
- Order [TN]: ungroup the apps and decide on the Services ID, then:
- "Team A obtains access token(s)"
- "generates transfer identifiers"
- "initiates app transfer"
- "Team B obtains access token(s)"
- "exchanges transfer identifiers"
- "confirms successful user migration"
- Other rules:
- Grouped apps must be ungrouped ([Overview of app transfer]; [T]: "The App Store Connect app won't let you transfer an app if it's part of an app group").
- The Services ID associated with the primary App ID transfers "If you don't want the Service ID to be transferred … remove its association before initiating the transfer" [TN].
- Relay addresses are team-scoped, and Invotek loses the channel to users "If a user doesn't use any other applications from your team" [T].
- "There is no test, debug or sandbox environment for Sign in with Apple, or app transfers" (DTS, forum 764888). The same thread suggests rehearsing with a test app transferred and back (review). That is a real option, not an Apple limit; see D.7.
- Errors for
usermigrationinfo(TN3107, "User migration info response errors", VERIFIED):- The section lists only
invalid_requestandinvalid_client.invalid_scopebelongs to the authorize endpoint, not here. - The section's introduction names a user who has revoked access to the client as a cause of failure. The review
reads it as a cause of
invalid_request. - Such a user fails permanently, so D.3 gives them a terminal status.
- A rate limit for
usermigrationinfois UNVERIFIED.
- The section lists only
- Firebase: nothing in its docs covers
transfer_sub(UNVERIFIED; see firebase-ios-sdk#7190). We need our own mapping.
D.3 Migration design¶
- Where it runs: a TypeScript tool in
reward-e/tools/siwa-migration/(Node 20,firebase-admin,jose), started by.github/workflows/siwa-migration.yml. - Trigger:
workflow_dispatchwith these inputs:step=generate|exchange|relink|verify|all;dry_run(defaulttrue);project=reward-e-dev|reward-e-app;only_uid(optional, for single-user rehearsal);transfer_accepted_at(ISO date, the TA). It is required forexchangeand later steps, and for the post-TA catch-upgenerate.
- GCP auth: the existing WIF secrets
GCP_WORKLOAD_IDENTITY_PROVIDERandGCP_SERVICE_ACCOUNT. The SA needs Firebase Authentication Admin and Cloud Datastore User onreward-e-app(operator grants), and the same onreward-e-devfor the rehearsal. - Why GitHub Actions: it is auditable, keeps keys off laptops, and reuses the repo's WIF. Rejected: a Cloud Run job (new infrastructure) and a laptop script (keys on disk).
- Deadline guard: every Apple call refuses to run after
transfer_accepted_at+ 59 days, and the summary prints the days left. - Apple keys: repo secrets, provisioned by OpenTofu; values never shown.
- Sender (Invotek):
SIWA_SENDER_KEY_ID,SIWA_SENDER_KEY_P8. - Recipient (Dashecorp):
SIWA_RECIPIENT_KEY_ID,SIWA_RECIPIENT_KEY_P8. - Non-secret workflow
env:SIWA_SENDER_TEAM_ID=V9GL74NMBR,SIWA_RECIPIENT_TEAM_ID=NEW_TEAM,SIWA_CLIENT_ID=no.invotek.RewardE. - State: Firestore
_siwaMigration/{firebaseUid}holding{ transferSub, newSub, isPrivateEmail, status, error?, attempts, updatedAt }. statusis one ofgenerated,exchanged,relinked,conflict,skipped_post_transfer,revoked_or_unmigratable(terminal) anderror(retryable).- Clients are denied by default.
firestore.ruleshas no match for it; add an explicit deny and a rules test. - The old
subis not stored. - Export rule: a conscious deviation.
- Apple says the export to the recipient must leave out the team-scoped identifier and the private email (D.2.2).
- In this design one Firebase project serves both sides. That project still holds Invotek's
sub(providerData.uid) and the old relay email until the relink, and the recipient-side job runs with access to it. - Nothing is exported. But the recipient-side tooling can reach exactly the data Apple says not to hand over, so the design does not meet the letter of the rule. The justification is that one operator controls both teams and the Firebase project.
- To keep the deviation small, only
generate(sender credentials) readsproviderData.uid.exchange,relinkandverifyread only_siwaMigrationrows, andgetUsersby UID for the email-domain check. They never read the oldsub. A test pins this (D.6.3).
- Delete the collection after TA+60 days.
generate(sender credentials; before T0, again on the day of T0, and a catch-up after T1, all before TA+60):- Page through
listUsers(1000). - For each user with a
providerDataentryproviderId=='apple.com', takeoldSub = providerData.uid. That this is the Applesubis UNVERIFIED: confirm the format on the operator's own user in the dry run. - Skip users already
generatedor later, and terminal rows. - Get a sender token (cached, re-requested at 55 minutes), then call
usermigrationinfo(sub, target=NEW_TEAM)and upserttransferSub. - Users created after
transfer_accepted_atalready have a Dashecorpsub. Mark themskipped_post_transfer. - Errors:
invalid_client→error, and the run stops: the credentials are wrong for everyone.invalid_request→ retry once with a fresh token and client secret. If it persists, the row becomesrevoked_or_unmigratable, with the Apple error code. Typically the user revoked Sign in with Apple for the app.- 5xx → retry with backoff, then
error.
exchange(recipient credentials; only after T1, before TA+60): forgeneratedrows, callusermigrationinfo(transfer_sub)and storenewSub,emailandisPrivateEmail. Errors are handled as in 4.6.relink(Firebase Admin), perexchangedrow:getUsers([{providerId:'apple.com', providerUid:newSub}])(VERIFIED API, at most 100 per call).- If that returns a different UID, a duplicate was created by a sign-in after T1:
- if the duplicate has no
membersdocument (collectionGroup('members').where('userId','==',dup)is empty) andresolve_empty_duplicates=true, delete the duplicate; - otherwise mark
conflictfor manual handling.
- if the duplicate has no
updateUser(uid, { providersToUnlink: ['apple.com'] }), thenupdateUser(uid, { providerToLink: { providerId: 'apple.com', uid: newSub, email } }).providerToLinkis PARTIAL: a snippet reads "Links this user to the specified provider" (UpdateRequest).providersToUnlinkexists in firebase-admin-node (auth-config.ts:187-190, review), so it is VERIFIED as API. Its exact semantics are proved by the dev rehearsal.
- If
isPrivateEmailand the user's email is atprivaterelay.appleid.com,updateUser(uid, {email: newEmail}). - Mark
relinked. The step is idempotent. verify: print counts:- Firebase users with
apple.com; - rows by status, with
revoked_or_unmigratablereported separately; - relinked users whose
getUsers(apple.com/newSub)returns the same UID; - users with
apple.combut no row.
The job summary gets counts only, with no identifiers.
8. Gates:
- U1 (and any external TestFlight distribution of NB1) is not submitted until verify shows 0 error and every
conflict is resolved.
- revoked_or_unmigratable does not block the gate. Those users cannot be migrated by Apple's API. They get a new
UID at their next Sign in with Apple, if they ever sign in again. The operator records the count.
- After U1, re-run exchange + relink + verify daily until TA+55 days, for users created between the last
generate and TA.
9. Optional fallback D3 (only if the T1→relink gap cannot be kept to hours):
- A callable Cloud Function claimAppleTransfer({appleIdToken}). It verifies the Apple ID token (JWKS,
aud=no.invotek.RewardE, iss=https://appleid.apple.com), reads its transfer_sub, finds the row, relinks the
original UID and deletes the empty duplicate.
- The app calls it when a sign-in lands on a new UID that has no family.
- ~250 LOC. Not in the recommended scope.
D.4 Firebase and store config¶
| Setting | Today | Change | When | Who |
|---|---|---|---|---|
| Firebase Auth → Apple provider: Services ID | UNVERIFIED (check the console) | None if the Services ID is associated with Reward-E's App ID: it moves to NEW_TEAM with the app [TN] |
— | operator checks |
| Firebase Auth → Apple provider: OAuth code flow (Team ID, Key ID, private key) | UNVERIFIED; probably empty, because only the native idToken flow is used (D.1.1) | If filled: Team ID → NEW_TEAM, Key ID and key → the Dashecorp Sign in with Apple key |
T1 | operator |
| Apple private email relay: email sources | UNVERIFIED | Only needed if Reward-E or Firebase sends email to relay addresses ("If you use any of Firebase Authentication's features that send emails to users", Firebase Apple, VERIFIED). Reward-E uses none | — | operator confirms none |
| App Check enforcement (Firestore only) | UNVERIFIED | If enforced: switch to monitor-only before the first NB1 build reaches TestFlight or App Review, and keep it off until at least 98% of verified requests come from NEW_TEAM builds. NB1 builds are Release builds with AppAttestProvider (AppCheckService.swift:47-49), so with enforcement on, a TestFlight tester or the App Review reviewer would hit Firestore permission errors. The 15-minute quote ("it can take up to 15 minutes after you enable enforcement for it to take effect", enable enforcement, VERIFIED) is about enabling. How long disabling takes is undocumented, so switch it off at least a day ahead. The 98% threshold is this spec's own number; Firebase's monitor metrics page says only "almost all". Functions are not affected (D.1.5) |
before NB1 → U1+n weeks | operator |
| App Check → Reward-E (iOS) → App Attest → Team ID | V9GL74NMBR (assumed) |
→ NEW_TEAM when the first NB1 build is uploaded to TestFlight, with enforcement already off. The single teamId means one build population always fails validation, so no moment is lockout-free with enforcement on. Switching at NB1 shows before release whether new-team attestation verifies, at the cost of the Invotek-signed production traffic showing as unverified until users update. Switching at the U1 release is the alternative; it keeps the metrics clean longer but gives no signal until release. Whether Firebase's assertion exchange re-checks the RP ID of an artifact from an earlier attestation is undocumented (UNVERIFIED) |
NB1 upload | operator |
| App Check DeviceCheck key | n/a | none (dead path, D.1.5) | — | — |
| FCM APNs key | n/a | none (FCM not used) | — | — |
Configuration.storekit:11, test, validator |
V9GL74NMBR |
the A5 mirror, flipped in the cutover PR | U1 PR | agent |
App Group group.no.invotek.RewardE |
Invotek | Invotek deletes it and Dashecorp registers it in one session, then enables it on the app and widget App IDs (A.6.1, steal window) | T1 | operator |
D.5 Runbook¶
Tags: [OP] operator only, [AG] agent PR or workflow dispatch, [CI] workflow run.
Prep (weeks before T0)
1. [AG] Merge A5, C1, D1a and D1b, with dry_run defaulting to true.
2. [AG] Rehearse relink on reward-e-dev with synthetic users (D.6 test 5).
3. [OP] In the Invotek portal:
- check whether no.invotek.RewardE is grouped for Sign in with Apple (e.g. with Cuti-E Admin); ungroup if so;
- list the Services IDs associated with it and decide whether to keep them (they transfer) or remove the association;
- note which Firebase Apple-provider fields are filled.
4. [OP] Create or identify an Invotek Sign in with Apple key enabled for primary App ID no.invotek.RewardE, store it in
Bitwarden, and apply SIWA_SENDER_* through OpenTofu.
5. [OP] Record NEW_TEAM (needed as target). Grant the WIF SA the roles in D.3.
6. [OP] Send any user communication to relay addresses now. Invotek loses that channel after T1.
7. [CI] generate with dry_run=true on reward-e-app (counts only). Then [OP] approves, and [CI] runs generate with
dry_run=false.
8. [OP] Record the App Check enforcement state. Freeze Reward-E releases. Remove the draft review submission and do the
TestFlight and Xcode Cloud cleanup (reference doc).
9. [CI] Catch-up generate on the day of T0. [OP] Invotek initiates the transfer (T0).
Transfer day (TA, then T1)
- [OP] Dashecorp accepts (TA; record the timestamp as
transfer_accepted_at, since the 60 days start here), then waits for "Processing App Transfer" to finish (T1). - [OP] Create the Dashecorp Sign in with Apple key with primary App ID
no.invotek.RewardE, now in the Dashecorp account.- Whether a key created earlier for a different primary App ID could sign for
no.invotek.RewardEis UNVERIFIED, so create it after T1. - Store it in Bitwarden and apply
SIWA_RECIPIENT_*through OpenTofu.
- Whether a key created earlier for a different primary App ID could sign for
- [CI] Catch-up
generate, withtransfer_accepted_atset. - [CI]
exchangethenrelinkwithonly_uid=<operator's own Firebase UID>.- [OP] On a device still running the Invotek-signed build: sign out, then Sign in with Apple. After T1 this returns
the recipient-team
sub(DTS 765778), so it tests the relink. - The same family must be visible, which means the same UID.
- If not, stop and debug. The 60-day window allows it.
- [OP] On a device still running the Invotek-signed build: sign out, then Sign in with Apple. After T1 this returns
the recipient-team
- [CI]
exchange,relinkandverifyfor everyone. Resolve conflicts. Record therevoked_or_unmigratablecount. - [OP] Firebase Apple provider fields per D.4 (only if they are filled).
- [OP] RevenueCat keys (E), the Cuti-E APNs row (C.3), and a re-check of the App Store Server Notifications URL
(
https://api.revenuecat.com/v1/subscribers/apple, production and sandbox). - [OP] App Group move in one session (D.4). Run
generate-match-profileswithteam_id=NEW_TEAMandapp_identifiers=no.invotek.RewardE,no.invotek.RewardE.Widget.
First Dashecorp build (NB1) and update (U1)
- [OP] If App Check is enforced: set it to monitor-only now, at least a day before step 20.
- [AG] Cutover PR:
ios/Team.xcconfigandConfiguration.storekit:11→NEW_TEAM. The guard and XCTest are green. - [CI] Build and upload NB1 to TestFlight (internal testers only). [OP] Set the App Check App Attest Team ID to
NEW_TEAM. Check that App Check metrics show verified requests from the NB1 build. - [OP] Gate (D.3.8), then submit for review. App Store Connect will warn about a possible keychain loss; that is expected (A.6.7). Release (U1).
- [OP] Monitor for 72 h:
- Crashlytics "Apple Sign-In: Firebase auth failed" (
AuthService.swift:221-222); - new rows in
conflict; - App Check metrics;
- Firestore permission errors.
- Crashlytics "Apple Sign-In: Firebase auth failed" (
Tail (to TA+60 days)
- [CI] Daily
exchange,relinkandverifyuntil TA+55 days. - [OP] Generate a new Dashecorp app-specific shared secret (E). Re-enforce App Check at the D.4 threshold.
- Regrouping (UNVERIFIED): [B] says to coordinate with the sending team and regroup after the transfer completes.
But if Reward-E's group partner stays at Invotek (e.g. Cuti-E Admin, whose Sign in with Apple audience is
com.cutie.admin:dashe-cutie/cloudflare-worker/src/handlers/apple-auth.js:397), there is no Dashecorp app to regroup with. Reward-E then stays its own primary App ID, and any regrouping is an Invotek-side action for its remaining apps. - No primary source says whether cross-team grouping is possible.
- Regrouping (UNVERIFIED): [B] says to coordinate with the sending team and regroup after the transfer completes.
But if Reward-E's group partner stays at Invotek (e.g. Cuti-E Admin, whose Sign in with Apple audience is
- [AG] After TA+60 days: delete
_siwaMigration, remove theSIWA_SENDER_*secrets, and delete the workflow or disable it (keep the tool for Cuti-E Admin, which uses Sign in with Apple too:dashe-cutie/cloudflare-worker/src/handlers/apple-auth.js).
D.6 Tests to write first¶
- JWT builder: header
alg=ES256andkid; claimsiss,sub=client_id,aud=https://appleid.apple.com, andexpat most15777000s afteriat. Signed with a throwaway P-256 key generated inside the test (no real key). - HTTP layer with a mocked
fetch: - form-urlencoded bodies exactly as in D.2;
Authorization: Bearer;- the token cache is refreshed at 55 minutes;
invalid_client→ rowerror, and the run stops;invalid_request→ one retry with a fresh token, thenrevoked_or_unmigratablewith the Apple error code;- 5xx retries with backoff;
- no call after
transfer_accepted_at+ 59 days. - Idempotency and isolation:
generate,exchangeandrelinkare idempotent: a second run writes nothing.dry_runperforms zero Firestore or Auth writes (spy on the admin mocks).exchange,relinkandverifynever readproviderData(spy).- Conflict handling and gates:
- a duplicate with no membership is deleted only when
resolve_empty_duplicatesis set; - a duplicate with a membership becomes
conflict; verify's gate passes withrevoked_or_unmigratablerows present, and fails with oneerrorrow.- Relink integration against the Firebase Auth emulator, or
reward-e-devif the emulator lacksproviderToLink: - Import a user
UwithproviderData apple.com uid=OLD. - Relink to
NEW. getUsers(apple.com/NEW)returnsU;getUsers(apple.com/OLD)returns nothing; the UID is unchanged; themembersdocument still resolves.- Firestore rules test (the repo has
firestore-rules-tests.yml): an authenticated client cannot read or write_siwaMigration/*. - iOS: A5's
SubscriptionConfigValidationTestschange.
D.7 Risks¶
- The 60-day window is missed. The recovery is "transfer the app back and forth" (VERIFIED), which costs weeks. The deadline guard keyed to TA, the daily catch-up and the calendar gate prevent it.
- Duplicates from sign-ins between T1 and the relink. Keep the gap to hours; conflict resolution handles the rest.
- The
providerToLink/providersToUnlinksemantics. Both exist as API; the dev rehearsal and the single-user rehearsal at T1 settle their behaviour. - The Apple half is first exercised at T1. Apple has no sandbox for transfers (VERIFIED). DTS 764888 suggests a test
transfer of a throwaway app and back (review). This spec does not do that: it costs a released app and weeks, and the
single-user rehearsal at step 13 is the control. Revisit if
NEW_TEAMexists months before Reward-E's turn. - Users who revoked Sign in with Apple cannot be migrated. They are counted, and they do not block U1.
- Every signed-in Reward-E iOS user signs in again on their first
NEW_TEAMbuild. Expect a spike in Crashlytics sign-in logs. The alternative, sharing Firebase Auth through the app-group keychain (useUserAccessGroup("group.no.invotek.RewardE")plusupdateCurrentUser, and B's DTS method), adds risk and is not recommended. - App Check lockout if enforcement is on when NB1 runs (D.4).
- The export-rule deviation (D.3.3). It is accepted because one operator controls both teams.
- Users who never open the app again: the batch relink covers them. Only users with no
transferSubat TA+60 days are lost (users created after the lastgeneratebut before TA). The day-of-T0 and T1 catch-ups close that gap.
D.8 Issue drafts¶
D1a. Repo dashecorp/dashe-reward-e. Title: "Sign in with Apple team transfer, part 1: transfer_sub generation tool (sender side)"
Why: Reward-E moves from team V9GL74NMBR to Dashecorp. Apple's
subis team-scoped. Every Firebase user's Apple identity must be mapped, or returning users land on a new, empty Firebase account. Apple procedure: TN3159 (spec D.2).Do: -
tools/siwa-migration/: - a client-secret JWT (ES256,kid= the Sign in with Apple key ID,iss=V9GL74NMBR,sub=no.invotek.RewardE,aud=https://appleid.apple.com); -POST https://appleid.apple.com/auth/tokenwithgrant_type=client_credentials&scope=user.migration; -POST https://appleid.apple.com/auth/usermigrationinfowithsub,target=<NEW_TEAM>,client_idandclient_secret. - Thegeneratestep overlistUsers, the_siwaMigration/{uid}schema with the terminalrevoked_or_unmigratablestatus, and the TA+59 deadline guard (spec D.3). -.github/workflows/siwa-migration.yml: workflow_dispatch, WIF auth,project = reward-e-dev|reward-e-app,dry_rundefaults totrue, and a job summary with counts only. -firestore.rules: explicit deny for_siwaMigration. - A dated design note indocs/, which records the export-rule deviation (spec D.3.3). -CLAUDE.md:297: the dev project isreward-e-dev.Secrets (OpenTofu, operator):
SIWA_SENDER_KEY_ID,SIWA_SENDER_KEY_P8.Tests first: spec D.6 1-3 (the generate path) and 6.
Acceptance: - [ ] Tests green. - [ ]
generatedry run onreward-e-appreports the number of apple.com users, and confirms that the operator's ownproviderData.uidhas Applesubformat. - [ ] A realgeneratestores atransferSubfor every apple.com user, or marks themrevoked_or_unmigratable, with 0errorrows.Est.: ~330 LOC including tests.
D1b. Repo dashecorp/dashe-reward-e. Title: "Sign in with Apple team transfer, part 2: exchange + Firebase re-link + verify (recipient side)"
Do: -
exchange:usermigrationinfowithtransfer_sub, signed withNEW_TEAM's key andiss=NEW_TEAM. -relink:getUsersconflict check, thenprovidersToUnlink ['apple.com'], thenproviderToLink {providerId:'apple.com', uid:newSub}, and a relay-email update. None of these steps reads the oldsubfromproviderData. -verify(terminal rows reported separately and excluded from the gate), theonly_uidinput, andresolve_empty_duplicates. - The runbook, spec D.5, asdocs/siwa-team-transfer-runbook.md.Secrets:
SIWA_RECIPIENT_KEY_ID,SIWA_RECIPIENT_KEY_P8, created after T1.Tests first: spec D.6 2-5.
Acceptance: - [ ] Emulator or
reward-e-devrehearsal: 20 synthetic users relinked, UIDs unchanged, lookup by the newsubworks. - [ ] The dry run on prod shows the planned operations and performs no writes.Est.: ~370 LOC including tests.
D-infra. Repo dashecorp/infra. Title: "Reward-E Sign in with Apple migration secrets": SIWA_SENDER_* now and
SIWA_RECIPIENT_* at T1, for dashe-reward-e only, plus the secrets-registry.md entry. Est.: ~20 LOC.
E. RevenueCat (Nutri-E, Reward-E): config only, operator does it¶
Both apps run purchases-ios 5.x with the default configuration, which uses StoreKit 2: Nutri-E 5.59.0 (its
Package.resolved), Reward-E 5.52.1 (ios/Package.resolved:122-127), set up at SubscriptionManager.swift:56-58 and
SubscriptionService.swift:101 (review). This decides which key matters.
| RevenueCat setting (per iOS app) | Team-bound? | Nutri-E | Reward-E | When |
|---|---|---|---|---|
In-App Purchase key (Key ID, Issuer ID, .p8) |
Yes: "You can use the same in-app purchase key for all App Store apps belonging to the same App Store Connect account" (IAP key, VERIFIED). "transactions will fail to be recorded without this key being set" | Replace with Dashecorp's. The live project proj2c32896f almost certainly has one today: it recorded a StoreKit 2 sync in production on 2026-09-10 (docs/subscriptions.md:212-230), which RevenueCat cannot record without the key (inference; one dashboard look confirms) |
Replace | Minutes after T1. This is the gate for purchases: with StoreKit 2, purchases are likely to fail between the Invotek key failing and the Dashecorp key being in (0.2). Also before Nutri-E's U1, for B2 (B.6) |
App Store Connect API key (Key ID, Issuer ID, .p8, Vendor number) |
Yes; used "to import products and prices" (ASC API key, VERIFIED). The Vendor number is Dashecorp's (Payments and Financial Reports) | Replace | Replace | Right after the IAP key |
| App-specific shared secret | No: it belongs to the app and travels with it. Apple has the recipient obtain it from the initiator, and generate a new one after the transfer "so that users outside of your organization no longer have access to it" (Overview of app transfer; Apple "Generate a shared secret", which also warns of "a transition period of up to 24 hours", review). RevenueCat: "mandatory for receipt validation when using StoreKit 1"; StoreKit 2 uses the IAP key (shared secret, VERIFIED) | Make sure one is configured before T0; RevenueCat staff call it "a fallback … while the transfer is taking place". For these StoreKit 2 apps that fallback probably does nothing (UNVERIFIED), so it is hygiene, not the safety net. After T1, Dashecorp regenerates it and enters it | same | before T0, then after T1 |
| App Store Server Notifications URL (set in ASC, per app) | Survival after a transfer is UNVERIFIED | Points at its own worker (nutrie-apple-webhook-worker-v2…/webhook), not RevenueCat. Repoint it to v3 before T0 (reference doc). B3 depends on this. Re-check it at T1 |
https://api.revenuecat.com/v1/subscribers/apple (production and sandbox, V2). Re-check at T1. RevenueCat's button "will automatically apply the URL in App Store Connect to both the Production and Sandbox environments" (RevenueCat ASSN, VERIFIED, review). That the button needs the Dashecorp ASC key is UNVERIFIED. Fallback: enter the URL by hand in the Dashecorp ASC account (App Information → App Store Server Notifications) |
T1 |
| Restore behaviour (project setting) | No, but B2 depends on it | Must be "Transfer to new App User ID" (the default, VERIFIED). It governs syncPurchases for Nutri-E's UUID appUserID (VERIFIED, B.6 B2.5). Check the production value, and that the sandbox override is off or the same |
n/a (identified by Firebase UID) | Before Nutri-E's B2 release |
| Webhook event filter | No | TRANSFER must be delivered by whintgr812efdd741 (production) and whintgr46e9d8efa9 (sandbox) (docs/subscriptions.md:136-142). The filter is UNVERIFIED |
n/a | Before Nutri-E's B2 release |
| SDK public API keys, bundle IDs, product IDs, entitlements, offerings | No | unchanged | unchanged | — |
| Project ownership (RevenueCat account) | No (not Apple) | Answered: the live project is proj2c32896f; proj61d576be is archived ("do not use", docs/subscriptions.md:22-31) |
Open: the embedded key maps to projdd4fe8ba (fact base); proj61d576be also holds a Reward-E iOS app record |
separate |
RevenueCat staff procedure (community, 2026-02-20, VERIFIED staff answer): 1. Before: "Ensure that your app has a app-specific shared secret and App Store Connect API key correctly configured". 2. After: "As soon as the Apple Developer Account change is complete, update the in-app purchase key", "Then update the app-specific shared secret and App Store Connect API key". 3. The reason for the shared secret: "Configuring the app-specific shared secret prior to the transfer ensures a fallback for RevenueCat validating/processing transactions while the transfer is taking place." As noted above, that fallback is a StoreKit 1 path.
Dashecorp can create its keys ahead of T1. Whether RevenueCat accepts and validates them before the app is in the Dashecorp account is UNVERIFIED, so have them ready in Bitwarden and upload them at T1.
Order of work (what ships first)¶
Phase 0: now. Nothing here needs the Dashecorp account.
- A0a, A0b, then A1-A6 (team as one setting). The guard is its own unfiltered workflow and a required check.
Count-E goes first, since its PR proves the
secrets[format()]andconfigFilesmechanics in this org; then the other five. A5 includes a TestFlight dispatch that proves the edited Matchfile. - B1 + B2 (Nutri-E), after [OP] registers
group.no.invotek.Nutri-Eand the profile is regenerated. Release them on the App Store under Invotek as early as possible. Adoption time is the long pole. - [OP] Repoint Nutri-E's App Store Server Notifications URL from v2 to v3 (reference doc; pre-existing drift). Then B3 (worker: refunds and expiries follow a transfer), deployed to v3.
- [OP] E checks for Nutri-E: the production restore behaviour, the sandbox override, and TRANSFER in both webhook integrations. B2's sandbox end-to-end test runs after them.
- B0 Count-E canary, released under Invotek before Count-E's T0. It can go in the same release as A1's first build.
- [OP] C.3.0: confirm the Cuti-E
appsrows (non-secret columns). Then C1 (Cuti-E backend, includingallow_custom_portsandapns_port), deployed before Fast-E's T0. - D1a + D1b (Reward-E tool), merged with
dry_run=true, rehearsed onreward-e-dev.
Phase 1: when NEW_TEAM exists.
- [OP] Create the Dashecorp ASC API key, In-App Purchase key and team-scoped Production APNs key. Apply
ASC_*_<NEW_TEAM>through OpenTofu. The distribution cert comes on the firstgenerate-match-profilesrun withallow_new_cert=true. The Sign in with Apple key waits until Reward-E's T1 (D.5.11). - Optional B-spike (two-team keychain).
- [CI] Reward-E
generate(needstarget=NEW_TEAM).
Phase 2: pilot, Count-E.
- T0 → TA → T1. Then [OP]:
- check whether the widget App ID arrived (A.6.4);
- move the App Group in one session and confirm it in Dashecorp's Identifiers list (A.6.1);
- create the cert and profiles on branch
NEW_TEAM.
- [AG] Cutover PR, then NB1 and U1. At submission, expect the ASC keychain warning (A.6.7). [OP] Checks the canary is
presenton 2 devices. - Go/no-go for Nutri-E's reliance on B1. B2 and B3 ship regardless.
Phase 3: Fast-E (after its subscription fix; reference doc).
- T1 → Cuti-E APNs PATCH with
apns_port=2197→ redeploy → targeted test push for Fast-E and for an Invotek app (C.3) → App Group (one session) → cutover → U1. This is the first real test of the APNs switch and of mixed-team pushes.
Phase 4: Nutri-E.
- Gate: at least 90% of active devices on the B1/B2 build, and the canary passed.
- T1 → RevenueCat In-App Purchase key within minutes, then the ASC key (E) → APNs PATCH → App Group
group.no.invotek.Nutri-Emoved in one session → cutover → U1. - Watch the
device_id_storeandidentity_recoveryvalues and the TRANSFER events.
Phase 5: Reward-E, last.
- Runbook D.5. App Check enforcement is off before NB1 reaches TestFlight.
Phase 6: tail.
- Remove the
_V9GL74NMBRsecrets once no repo uses them. - Keep Invotek renewed until the last app and its credentials have moved (reference doc).
Open questions for the operator¶
- When will
NEW_TEAMexist? It blocks A's cutovers, the Reward-Egenerate, the B-spike and every key creation. - Nutri-E (RevenueCat,
proj2c32896f): - How many active paying subscribers are there?
- Confirm in the dashboard that the production restore behaviour is "Transfer to new App User ID", that the
sandbox override is off (or the same), and that
whintgr812efdd741andwhintgr46e9d8efa9deliver TRANSFER. - Confirm with one look that an In-App Purchase key is configured (expected, E).
- Nutri-E: may we register App Group
group.no.invotek.Nutri-Ein the Invotek account and regenerate the Nutri-E App Store profile with a match write run? - Nutri-E transfer gate: at least 90% of 30-day active devices on the migration build (ASC App Analytics), or a different threshold or date?
- Reward-E:
- How many Firebase users have an
apple.comprovider? - Is
no.invotek.RewardEgrouped for Sign in with Apple, and with which apps? - Which Services ID and Sign in with Apple key exist at Invotek?
- Are the Firebase Apple-provider code-flow fields filled?
- Which RevenueCat account owns
projdd4fe8ba? - Reward-E:
- Is Firebase App Check enforced on Firestore? (Functions are not affected, D.1.5.)
- Switch the App Attest Team ID at the first NB1 TestFlight upload (recommended, D.4), or at the U1 release?
- Do you accept one forced Sign in with Apple for every signed-in iOS user at U1 (recommended), rather than the riskier shared-keychain alternative?
- Do you accept that users who revoked Sign in with Apple are not migrated (
revoked_or_unmigratable) and get a new account at their next sign-in? - Reward-E: approve running the migration tool from GitHub Actions with the WIF service account, plus the roles
Firebase Authentication Admin and Cloud Datastore User on
reward-e-appandreward-e-dev. Approve the export-rule deviation in D.3.3 (one Firebase project, one operator for both teams). - Secrets: approve team-suffixed ASC secret names (
ASC_*_<TEAM>) indashe-secrets.tf, and reuse of the sameMATCH_PASSWORDfor the Dashecorp match branch. - Cuti-E:
- Confirm from Cloudflare analytics that
/v1/device/check/*gets no traffic. If so, the DeviceCheck config stays untouched. - List the
appsrows' non-secret APNs columns (C.3.0). - Nutri-E: may we delete the legacy fastlane lanes, ExportOptions plists and deploy scripts (A6)?
generate-match-profiles.ymllives in dashe-drink-e, which is not transferred. Keep it there or move it todashecorp/ios-certs?- Pre-existing security issue. Nutri-E's
X-Device-Secretsalt ships in the app binary (DeviceAuthService.swift:14-16), so any known device ID can use that device's quota and tier. File it as a separate issue, to be fixed after the transfer, not with B1.
Answered by the review (removed from the list):
- Which RevenueCat project is live for Nutri-E, and does the archived record's missing IAP key matter? The live project
is proj2c32896f, and proj61d576be is archived (nutri-e/docs/subscriptions.md:22-31). The missing key was on the
archived project. The live project's StoreKit 2 production sync on 2026-09-10 (:212-230) implies the key exists.
- Does the restore behaviour govern syncPurchases? Yes, for Nutri-E's identified UUID user (purchases-ios 5.59.0
isRestore logic plus RevenueCat's Restore Behavior page, B.6 B2.5).
- Does Firebase Auth lose its session at U1? Yes, for signed-in iOS users (FIRAuthKeychainServices.m@10.29.0, D.1.4).
- Does the Invotek APNs key stop at T1? Yes, after a short propagation delay (DTS 744468, 0.2).
- Do configFiles, secrets[format()] and per-team match branches work? Yes, per XcodeGen, GitHub and fastlane docs
and source (A.2). The Count-E PR still proves them in this org.
- Does App Check enforcement cover Functions? No: Reward-E has only Firestore triggers (D.1.5).
Sources¶
Apple, all fetched 2026-09-25: - Overview of app transfer - Accept an app transfer - TN2311 - Sharing access to keychain items - kSecAttrAccessGroup - App Groups entitlement - Forum threads 706128 and 821062 (Quinn, DTS) - Validating apps that connect to your server - Establishing your app's integrity - APNs token-based connection - Sending notification requests - APNs and app transfer, DTS: forums 744468, 760355 and 795723 (the latter two: review) - identifierForVendor - Updating your account information - appAccountToken - Set App Account Token - Sign in with Apple: transferring, bringing in - TN3159 - TN3107 - Creating a client secret - DTS forum threads 764888, 765778 and 770264
Firebase:
- Authenticate using Apple
- Manage users
- Import users
- App Attest provider
- App Attest codelab
- DeviceCheck provider
- Enable enforcement
- Monitor metrics
- SDK source: FIRAuthKeychainServices.m@10.29.0; FIRAuth.m@10.29.0 and firebase-admin-node auth-config.ts (review); the Management API IosApp.teamId (review)
RevenueCat: - In-App Purchase key - App Store Connect API key - Shared secret - Apple server notifications - Restore behavior - Restoring purchases - Identifying customers - Webhook event types - Community staff answer, 2026-02-20 - SDK source: PurchasesOrchestrator.swift@5.59.0
Build tooling:
- fastlane: configuration_file.rb (bare eval, config values never override passed options); match git_branch docs (review)
- XcodeGen ProjectSpec.md (configFiles), the GitHub Actions contexts docs and the actions/runner source (review)
- Cloudflare Workers compatibility flags (allow_custom_ports, default from 2024-09-02) (review; outside this revision's
fetch allow-list)