@@ -138,45 +138,55 @@ class SiteProvisioningSource @Inject constructor(
138138 private fun shouldRun (siteLocalId : Int ): Boolean =
139139 jobs[siteLocalId]?.isActive != true && siteLocalId !in ready
140140
141- private suspend fun runPipeline (siteLocalId : Int ): SiteReadiness =
142- when (val auth = ensureAuth(siteLocalId)) {
141+ private suspend fun runPipeline (siteLocalId : Int ): SiteReadiness {
142+ val auth = ensureAuth(siteLocalId)
143+ return when (auth.state) {
143144 SiteAuthState .Provisioned , SiteAuthState .NotApplicable -> coroutineScope {
144- // Post-auth, the REST-capability chain and the XML-RPC recovery are independent —
145- // each reads the site fresh and writes only its own column — so run them in
146- // parallel. recoverRestUrlIfNeeded precedes detectCapabilities within its branch because
147- // the probe needs the recovered REST root.
148- val capabilities = async { recoverRestUrlIfNeeded(siteLocalId); detectCapabilities(siteLocalId) }
145+ // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — each
146+ // reads the site fresh and writes only its own column — so run them in parallel.
147+ // recoverRestUrlIfNeeded precedes detectCapabilities within its branch because the probe
148+ // needs the recovered REST root. The mint's credentials reach the probe as an immutable
149+ // value (auth.credentials), never via a shared mutated SiteModel — the fresh-read columns
150+ // can't be trusted to carry them (transient, and clobberable by a concurrent write, #22905).
151+ val capabilities = async {
152+ recoverRestUrlIfNeeded(siteLocalId)
153+ detectCapabilities(siteLocalId, auth.credentials)
154+ }
149155 val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) }
150156 xmlRpc.await()
151157 capabilities.await()
152158 }
153- else -> SiteReadiness .NeedsAuth (auth)
159+ else -> SiteReadiness .NeedsAuth (auth.state )
154160 }
161+ }
155162
156163 /* *
157- * Stage 1 — ensure the site has working application-password credentials.
158- * Validates stored creds with Basic auth against the direct host; on a
159- * confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack
160- * tunnel. The mint persists the credentials, so later stages read them back.
164+ * Stage 1 — ensure the site has working application-password credentials, and
165+ * return them. Validates stored creds with Basic auth against the direct host;
166+ * on a confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack
167+ * tunnel. The credentials are returned in the [AuthResult] so [detectCapabilities]
168+ * can authenticate with them without trusting a fresh re-read — the plain columns
169+ * are transient and a concurrent whole-row site write can clobber the encrypted
170+ * ones mid-run (#22905).
161171 */
162172 // Each return is a distinct auth outcome (missing site, valid, transient, minted, failed);
163173 // collapsing to one return would thread a result through nested branches and read worse.
164174 @Suppress(" ReturnCount" )
165- private suspend fun ensureAuth (siteLocalId : Int ): SiteAuthState {
175+ private suspend fun ensureAuth (siteLocalId : Int ): AuthResult {
166176 val site = siteStore.getSiteByLocalId(siteLocalId)
167- ? : return SiteAuthState .Unprovisionable (hadCredentials = false )
177+ ? : return AuthResult ( SiteAuthState .Unprovisionable (hadCredentials = false ) )
168178 // WP.com Simple sites are fully proxied and OAuth-bearer-authed — no application password
169179 // applies (the mint returns NotSupported). Capability detection works through the proxy, so
170180 // treat them as ready instead of blocking detection behind a mint that can never run.
171- if (site.isWPComSimpleSite) return SiteAuthState .NotApplicable
181+ if (site.isWPComSimpleSite) return AuthResult ( SiteAuthState .NotApplicable )
172182 val hadCredentials = ! applicationPasswordLoginHelper.siteHasBadCredentials(site)
173183 if (hadCredentials) {
174184 when (applicationPasswordValidator.validate(site)) {
175185 ApplicationPasswordValidator .Outcome .Valid ->
176- return SiteAuthState .Provisioned
186+ return AuthResult ( SiteAuthState .Provisioned , site.provisionedCredentials())
177187 ApplicationPasswordValidator .Outcome .NetworkUnavailable -> {
178188 appLogWrapper.d(AppLog .T .MAIN , " A_P: Validation network error for ${site.url} " )
179- return SiteAuthState .Provisioning
189+ return AuthResult ( SiteAuthState .Provisioning )
180190 }
181191 ApplicationPasswordValidator .Outcome .Invalid -> {
182192 appLogWrapper.d(AppLog .T .MAIN , " A_P: Stored creds invalid for ${site.url} , clearing" )
@@ -185,23 +195,24 @@ class SiteProvisioningSource @Inject constructor(
185195 }
186196 }
187197 }
198+ // createApplicationPassword mutates this local `site` with the freshly minted plain credentials.
188199 val createResult = siteStore.createApplicationPassword(site)
189200 if (! createResult.isError && createResult.credentials != null ) {
190201 wpApiClientProvider.clearSelfHostedClient(site.id)
191202 appLogWrapper.d(AppLog .T .MAIN , " A_P: Headless mint succeeded for ${site.url} " )
192- return SiteAuthState .Provisioned
203+ return AuthResult ( SiteAuthState .Provisioned , site.provisionedCredentials())
193204 }
194205 appLogWrapper.d(
195206 AppLog .T .MAIN ,
196207 " A_P: Headless mint failed for ${site.url} (notSupported=${createResult.error?.notSupported} )"
197208 )
198- return SiteAuthState .Unprovisionable (hadCredentials = hadCredentials)
209+ return AuthResult ( SiteAuthState .Unprovisionable (hadCredentials = hadCredentials) )
199210 }
200211
201212 /* *
202213 * Stage 2a — recover the REST API root for Atomic sites minted through the
203214 * Jetpack tunnel (which never runs discovery and leaves `wpApiRestUrl` null).
204- * Persists the one column; the capability probe re-reads it.
215+ * Persists the one column; the capability probe (sequenced after it) re-reads it.
205216 */
206217 private suspend fun recoverRestUrlIfNeeded (siteLocalId : Int ) {
207218 val site = siteStore.getSiteByLocalId(siteLocalId) ? : return
@@ -229,12 +240,25 @@ class SiteProvisioningSource @Inject constructor(
229240
230241 /* *
231242 * Stage 3 — probe the REST API for editor-capability support and persist it.
232- * Reached only once auth is [SiteAuthState.Provisioned], so credentials are
233- * guaranteed present: a failure here is a real transport problem, not a
234- * pending mint.
243+ * Reached only once auth is [SiteAuthState.Provisioned] / [SiteAuthState.NotApplicable].
244+ * [credentials] (from [ensureAuth]) are overlaid onto this run-local copy so the
245+ * authenticated direct-host probe can run even when the fresh read doesn't reflect
246+ * the mint; a failure here is then a real transport problem, not a pending mint.
235247 */
236- private suspend fun detectCapabilities (siteLocalId : Int ): SiteReadiness {
248+ private suspend fun detectCapabilities (
249+ siteLocalId : Int ,
250+ credentials : ProvisionedCredentials ? ,
251+ ): SiteReadiness {
237252 val site = siteStore.getSiteByLocalId(siteLocalId) ? : return SiteReadiness .Unreachable
253+ // Overlay the credentials ensureAuth obtained. The fresh read can't be trusted to carry them:
254+ // the plain columns are transient (never persisted), and a concurrent whole-row site write
255+ // during My Site load can clobber the encrypted ones before this read (#22905). This copy is
256+ // local to this coroutine — not shared with the parallel XML-RPC stage — so the overlay is
257+ // race-free.
258+ credentials?.let {
259+ if (site.apiRestUsernamePlain.isNullOrEmpty()) site.apiRestUsernamePlain = it.username
260+ if (site.apiRestPasswordPlain.isNullOrEmpty()) site.apiRestPasswordPlain = it.password
261+ }
238262 val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site)
239263 val hasCache = editorSettingsRepository.hasCachedCapabilities(site)
240264 return when {
@@ -245,6 +269,26 @@ class SiteProvisioningSource @Inject constructor(
245269 }
246270}
247271
272+ /* *
273+ * Snapshot of the application-password credentials [SiteProvisioningSource.ensureAuth] obtained,
274+ * handed forward to the capability probe as an immutable value rather than via a shared, mutated
275+ * [SiteModel] — so the parallel stages never race on it.
276+ */
277+ private data class ProvisionedCredentials (val username : String , val password : String )
278+
279+ private fun SiteModel.provisionedCredentials (): ProvisionedCredentials ? {
280+ val user = apiRestUsernamePlain
281+ val pass = apiRestPasswordPlain
282+ return if (! user.isNullOrEmpty() && ! pass.isNullOrEmpty()) ProvisionedCredentials (user, pass) else null
283+ }
284+
285+ /* *
286+ * [SiteProvisioningSource.ensureAuth]'s outcome: the public [SiteAuthState] plus, when provisioned,
287+ * the credentials to hand to the capability probe. Internal so the credentials never leak into the
288+ * UI-facing [SiteReadiness] / [SiteAuthState].
289+ */
290+ private data class AuthResult (val state : SiteAuthState , val credentials : ProvisionedCredentials ? = null )
291+
248292/* *
249293 * Whether a site's application password is usable. Owned by [SiteProvisioningSource];
250294 * rendered by the application-password card.
0 commit comments