@@ -173,6 +173,7 @@ async def test_connection(self) -> dict[str, Any]:
173173 if version_error is not None :
174174 return version_error
175175
176+ await self ._backfill_user_id_best_effort ()
176177 return self ._success_result (version )
177178
178179 async def probe_reachability (self ) -> dict [str , Any ]:
@@ -240,6 +241,10 @@ async def establish_token(
240241 self ._settings ["romm_api_token" ] = None
241242 self ._settings ["romm_api_token_id" ] = None
242243 self ._settings ["romm_api_token_origin" ] = None
244+ # A new sign-in invalidates the stored identity — re-derived below from
245+ # the freshly minted token, so it can never linger for a different user
246+ # or a different server.
247+ self ._settings ["romm_user_id" ] = None
243248
244249 try :
245250 version = await self ._loop .run_in_executor (None , self ._probe_version )
@@ -289,6 +294,14 @@ async def establish_token(
289294 "message" : "RomM did not return a usable token" ,
290295 }
291296
297+ # Host-bind the minted token in memory so the identity probe
298+ # authenticates, then stamp the user id — both ride the single atomic
299+ # token-persist save below (the mint response carries only the token id,
300+ # not the user id, so /api/users/me is the only source here).
301+ self ._settings ["romm_api_token" ] = raw_token
302+ self ._settings ["romm_api_token_origin" ] = normalize_origin (trimmed )
303+ await self ._resolve_user_id_in_memory ()
304+
292305 try :
293306 self ._persist_token (raw_token , token_id , origin = normalize_origin (trimmed ), source = "minted" )
294307 except Exception as e :
@@ -344,6 +357,9 @@ async def establish_user_token(
344357 self ._settings ["romm_api_token_id" ] = None
345358 self ._settings ["romm_api_token_origin" ] = normalize_origin (trimmed )
346359 self ._settings ["romm_api_token_source" ] = "user"
360+ # A new sign-in invalidates the stored identity — re-derived from the
361+ # pasted token's /api/users/me validation probe below.
362+ self ._settings ["romm_user_id" ] = None
347363
348364 try :
349365 version = await self ._loop .run_in_executor (None , self ._probe_version )
@@ -403,6 +419,9 @@ async def establish_paired_token(
403419 self ._settings ["romm_api_token" ] = None
404420 self ._settings ["romm_api_token_id" ] = None
405421 self ._settings ["romm_api_token_origin" ] = None
422+ # A new sign-in invalidates the stored identity — re-derived from the
423+ # exchanged token's /api/users/me validation probe below.
424+ self ._settings ["romm_user_id" ] = None
406425
407426 try :
408427 version = await self ._loop .run_in_executor (None , self ._probe_version )
@@ -475,7 +494,7 @@ async def _validate_and_persist_user_token(
475494 token value is never logged.
476495 """
477496 try :
478- await self ._loop .run_in_executor (None , self ._romm_api .get_current_user )
497+ user_data = await self ._loop .run_in_executor (None , self ._romm_api .get_current_user )
479498 except RommAuthError :
480499 self ._restore_auth_state (snapshot )
481500 return {"success" : False , "reason" : ErrorCode .AUTH_FAILED .value , "message" : _USER_TOKEN_INVALID_MESSAGE }
@@ -486,6 +505,11 @@ async def _validate_and_persist_user_token(
486505 self ._restore_auth_state (snapshot )
487506 return error_response (e )
488507
508+ # Stamp the user identity in memory so it rides the single atomic
509+ # token-persist save (the validation probe already returned it — no
510+ # second /api/users/me call).
511+ self ._set_user_id_in_memory (user_data )
512+
489513 try :
490514 self ._persist_token (raw_token , None , origin = normalize_origin (trimmed ), source = "user" )
491515 except Exception as e :
@@ -536,6 +560,72 @@ async def _forget_device_on_origin_change(self, old_token_origin: str | None, ne
536560 except Exception as e :
537561 self ._logger .warning (f"Could not clear device id after origin change: { e } " )
538562
563+ @staticmethod
564+ def _extract_user_id (user_data : object ) -> int | None :
565+ """Read the RomM user id off a ``/api/users/me`` dict, or ``None``.
566+
567+ Tolerant of an untrusted server shape: a non-dict payload, a missing
568+ ``id``, a non-int, or a bool (``isinstance(True, int)`` is ``True`` in
569+ Python) all yield ``None`` so a malformed identity never becomes a wrong
570+ owner-scope filter.
571+ """
572+ user_id = user_data .get ("id" ) if isinstance (user_data , dict ) else None
573+ if isinstance (user_id , bool ) or not isinstance (user_id , int ):
574+ return None
575+ return user_id
576+
577+ def _set_user_id_in_memory (self , user_data : object ) -> None :
578+ """Stamp ``settings['romm_user_id']`` from a ``/api/users/me`` dict, no save.
579+
580+ The value rides the sign-in's single atomic token-persist save, so
581+ identity lands on disk together with the token (never a second write). A
582+ malformed payload yields ``None`` — "Own" then degrades to "All" until
583+ the lazy backfill re-derives it.
584+ """
585+ self ._settings ["romm_user_id" ] = self ._extract_user_id (user_data )
586+
587+ async def _resolve_user_id_in_memory (self ) -> None :
588+ """Fetch the minted token's user identity into memory (best-effort, no save).
589+
590+ The mint response carries only the token id, not the user id, so the
591+ freshly host-bound token is probed against ``/api/users/me``.
592+ Best-effort: a failure leaves ``romm_user_id`` cleared (``None``) so the
593+ single token-persist save records ``None`` and the lazy backfill
594+ re-derives it on the next connection check. Never fails the sign-in.
595+ """
596+ try :
597+ user_data = await self ._loop .run_in_executor (None , self ._romm_api .get_current_user )
598+ except Exception as e :
599+ self ._logger .warning (f"Could not read RomM user identity at sign-in: { e } " )
600+ return
601+ self ._set_user_id_in_memory (user_data )
602+
603+ async def _backfill_user_id_best_effort (self ) -> None :
604+ """Lazily stamp ``romm_user_id`` from the current token when it is unknown.
605+
606+ Existing installs carry a valid token but no stored identity (the setting
607+ postdates them). This backfills it on the next connection check so the
608+ collection owner-scope filter can activate without a re-login. Fires only
609+ when the id is missing (a known id needs no network) and a token exists;
610+ persists in its own save (no token write is in flight here). Best-effort
611+ — a failure or malformed payload leaves the id unknown, so "Own" keeps
612+ behaving like "All".
613+ """
614+ if self ._settings .get ("romm_user_id" ) is not None :
615+ return
616+ if not self ._settings .get ("romm_api_token" ):
617+ return
618+ try :
619+ user_data = await self ._loop .run_in_executor (None , self ._romm_api .get_current_user )
620+ except Exception as e :
621+ self ._logger .warning (f"Could not backfill RomM user identity: { e } " )
622+ return
623+ user_id = self ._extract_user_id (user_data )
624+ if user_id is None :
625+ return
626+ self ._settings ["romm_user_id" ] = user_id
627+ self ._settings_persister .save_settings ()
628+
539629 async def migrate_legacy_credentials (self ) -> None :
540630 """Upgrade a stored-password install to a Client API Token on startup.
541631
@@ -601,6 +691,9 @@ def sign_out(self) -> dict[str, Any]:
601691 self ._settings ["romm_api_token_id" ] = None
602692 self ._settings ["romm_api_token_origin" ] = None
603693 self ._settings ["romm_api_token_source" ] = None
694+ # Identity is forgotten alongside the token — "Own" collection scope has
695+ # no basis without a signed-in user, and the next sign-in re-derives it.
696+ self ._settings ["romm_user_id" ] = None
604697 try :
605698 self ._settings_persister .save_settings ()
606699 except Exception as e :
@@ -618,6 +711,9 @@ def sign_out(self) -> dict[str, Any]:
618711 "romm_api_token_id" ,
619712 "romm_api_token_origin" ,
620713 "romm_api_token_source" ,
714+ # Identity is bound to the token: a failed sign-in restores the previous
715+ # user id, never leaving a half-changed identity behind.
716+ "romm_user_id" ,
621717 )
622718
623719 def _snapshot_auth_state (self ) -> dict [str , Any ]:
0 commit comments