@@ -140,53 +140,49 @@ class SiteProvisioningSource @Inject constructor(
140140
141141 private suspend fun runPipeline (siteLocalId : Int ): SiteReadiness {
142142 val auth = ensureAuth(siteLocalId)
143- return when (auth.state ) {
143+ return when (auth) {
144144 SiteAuthState .Provisioned , SiteAuthState .NotApplicable -> coroutineScope {
145145 // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — each
146146 // reads the site fresh and writes only its own column — so run them in parallel.
147147 // 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).
148+ // needs the recovered REST root. Both branches read the credentials fresh from the store:
149+ // the mint persists them via a single writer that the generic full-row update can no
150+ // longer clobber (#22947), so the re-read is now trustworthy ( #22905).
151151 val capabilities = async {
152152 recoverRestUrlIfNeeded(siteLocalId)
153- detectCapabilities(siteLocalId, auth.credentials )
153+ detectCapabilities(siteLocalId)
154154 }
155155 val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) }
156156 xmlRpc.await()
157157 capabilities.await()
158158 }
159- else -> SiteReadiness .NeedsAuth (auth.state )
159+ else -> SiteReadiness .NeedsAuth (auth)
160160 }
161161 }
162162
163163 /* *
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).
164+ * Stage 1 — ensure the site has working application-password credentials. Validates stored creds
165+ * with Basic auth against the direct host; on a confirmed rejection wipes them and mints fresh
166+ * ones via the FluxC Jetpack tunnel. The mint persists the credentials (single-writer, #22947), so
167+ * the downstream stages read them back from a fresh [SiteModel] rather than having them threaded.
171168 */
172169 // Each return is a distinct auth outcome (missing site, valid, transient, minted, failed);
173170 // collapsing to one return would thread a result through nested branches and read worse.
174171 @Suppress(" ReturnCount" )
175- private suspend fun ensureAuth (siteLocalId : Int ): AuthResult {
172+ private suspend fun ensureAuth (siteLocalId : Int ): SiteAuthState {
176173 val site = siteStore.getSiteByLocalId(siteLocalId)
177- ? : return AuthResult ( SiteAuthState .Unprovisionable (hadCredentials = false ) )
174+ ? : return SiteAuthState .Unprovisionable (hadCredentials = false )
178175 // WP.com Simple sites are fully proxied and OAuth-bearer-authed — no application password
179176 // applies (the mint returns NotSupported). Capability detection works through the proxy, so
180177 // treat them as ready instead of blocking detection behind a mint that can never run.
181- if (site.isWPComSimpleSite) return AuthResult ( SiteAuthState .NotApplicable )
178+ if (site.isWPComSimpleSite) return SiteAuthState .NotApplicable
182179 val hadCredentials = ! applicationPasswordLoginHelper.siteHasBadCredentials(site)
183180 if (hadCredentials) {
184181 when (applicationPasswordValidator.validate(site)) {
185- ApplicationPasswordValidator .Outcome .Valid ->
186- return AuthResult (SiteAuthState .Provisioned , site.provisionedCredentials())
182+ ApplicationPasswordValidator .Outcome .Valid -> return SiteAuthState .Provisioned
187183 ApplicationPasswordValidator .Outcome .NetworkUnavailable -> {
188184 appLogWrapper.d(AppLog .T .MAIN , " A_P: Validation network error for ${site.url} " )
189- return AuthResult ( SiteAuthState .Provisioning )
185+ return SiteAuthState .Provisioning
190186 }
191187 ApplicationPasswordValidator .Outcome .Invalid -> {
192188 appLogWrapper.d(AppLog .T .MAIN , " A_P: Stored creds invalid for ${site.url} , clearing" )
@@ -195,18 +191,19 @@ class SiteProvisioningSource @Inject constructor(
195191 }
196192 }
197193 }
198- // createApplicationPassword mutates this local `site` with the freshly minted plain credentials.
194+ // createApplicationPassword mints and persists the credentials; downstream stages read them
195+ // back from a fresh SiteModel.
199196 val createResult = siteStore.createApplicationPassword(site)
200197 if (! createResult.isError && createResult.credentials != null ) {
201198 wpApiClientProvider.clearSelfHostedClient(site.id)
202199 appLogWrapper.d(AppLog .T .MAIN , " A_P: Headless mint succeeded for ${site.url} " )
203- return AuthResult ( SiteAuthState .Provisioned , site.provisionedCredentials())
200+ return SiteAuthState .Provisioned
204201 }
205202 appLogWrapper.d(
206203 AppLog .T .MAIN ,
207204 " A_P: Headless mint failed for ${site.url} (notSupported=${createResult.error?.notSupported} )"
208205 )
209- return AuthResult ( SiteAuthState .Unprovisionable (hadCredentials = hadCredentials) )
206+ return SiteAuthState .Unprovisionable (hadCredentials = hadCredentials)
210207 }
211208
212209 /* *
@@ -226,8 +223,10 @@ class SiteProvisioningSource @Inject constructor(
226223
227224 /* *
228225 * Stage 2b (parallel) — recover the XML-RPC endpoint for true self-hosted
229- * sites that don't have one. Discovers + authenticates against it, and on
230- * success persists the one column; the application-password card re-reads it.
226+ * sites that don't have one. Discovers + authenticates against it with the
227+ * site's application-password credentials (which work for XML-RPC just as for
228+ * REST), and on success persists the one column; the application-password card
229+ * re-reads it.
231230 */
232231 private suspend fun recoverXmlRpcIfNeeded (siteLocalId : Int ) {
233232 val site = siteStore.getSiteByLocalId(siteLocalId) ? : return
@@ -241,24 +240,11 @@ class SiteProvisioningSource @Inject constructor(
241240 /* *
242241 * Stage 3 — probe the REST API for editor-capability support and persist it.
243242 * 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.
243+ * Reads the site fresh: the mint has already persisted the credentials (single-writer, #22947),
244+ * so a probe failure here is a real transport problem, not a pending mint.
247245 */
248- private suspend fun detectCapabilities (
249- siteLocalId : Int ,
250- credentials : ProvisionedCredentials ? ,
251- ): SiteReadiness {
246+ private suspend fun detectCapabilities (siteLocalId : Int ): SiteReadiness {
252247 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- }
262248 val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site)
263249 val hasCache = editorSettingsRepository.hasCachedCapabilities(site)
264250 return when {
@@ -269,26 +255,6 @@ class SiteProvisioningSource @Inject constructor(
269255 }
270256}
271257
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-
292258/* *
293259 * Whether a site's application password is usable. Owned by [SiteProvisioningSource];
294260 * rendered by the application-password card.
0 commit comments