Skip to content

Commit 236514e

Browse files
authored
feat(ui): add Own/All collection owner-scope filter with lazy identity (#1537)
Adds an optional QAM Own/All toggle so a user on a shared RomM server syncs only their own collections instead of all (which includes other users' public ones). Default "All" is byte-for-byte today's behaviour. "Own" filters foreign user/smart collections out of the work queue (a sync scope over the enabled-state, never mutating it); franchise/virtual collections have no owner and always sync. The plugin now reads and stores its own RomM user id from /api/users/me, bound to the token: stamped at sign-in, lazily backfilled for existing installs (no re-login), re-derived on every sign-in (catches same-origin user switches), and when unknown "Own" safely behaves like "All". Also renames the internal collection kind-scope "my" to "user" (behaviour-preserving, not persisted) so it doesn't collide with the new owner-scope "own". Closes #1532
1 parent 6662280 commit 236514e

22 files changed

Lines changed: 989 additions & 37 deletions

docs/architecture/backend-architecture.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,9 +510,25 @@ identity — the same identity the #742 completion stamp uses — with the displ
510510
reporter (`_resolve_collection_memberships`) then groups the accumulator's entries by name and **unions** their resolved
511511
appId sets (order-preserving, de-duplicated across collections; each collection's own resolution already dedups within),
512512
emitting the unchanged by-name `romm_collection_app_ids: {name → [appId]}` contract — a single-collection name unions a
513-
set of one and is byte-for-byte the pre-#1503 output. The plugin **unions rather than owner-filters** here: RomM
514-
deliberately exposes public collections across accounts, so syncing them unfiltered is the established behaviour (an
515-
own/all QAM toggle is a separate concern).
513+
set of one and is byte-for-byte the pre-#1503 output. The union runs **after** the owner-scope filter (below): once a
514+
foreign collection is dropped from the work queue it never reaches the accumulator, so "Own" narrows what is unioned
515+
rather than changing how the union works.
516+
517+
**Collection owner-scope filter — "Own" is a sync scope, not just a display filter (#1532).** RomM's collection list
518+
endpoints return the signed-in user's own collections plus every other user's _public_ collection. The QAM
519+
`Show
520+
collections` control writes `collection_owner_scope` (`"own"` / `"all"`, default `"all"`); `get_collections` also
521+
tags each row with `is_own` so the frontend can hide foreign ones under "Own". Ownership is a pure predicate
522+
(`domain/collection_owner.is_own_collection`): a collection is own when it is a **franchise/virtual** collection (RomM's
523+
`VirtualCollection` model carries no `user_id` column — these are global/derived and belong to no one, so they always
524+
survive), when the plugin's own identity is unknown (the **degrade-to-"All" fallback**), or when the collection's
525+
`user_id` equals the stored `romm_user_id`; only user and smart collections carry a `user_id` to compare.
526+
`build_work_queue` applies the same predicate: under `"own"` **with a known identity** it drops foreign user/smart units
527+
from the queue — so a foreign collection enabled earlier is never synced — while franchise units and every unit under
528+
`"all"` pass through unchanged. The scope filters **over** the per-kind enable state without mutating it, so switching
529+
back to `"all"` restores the prior enables. Because an **unknown identity never filters**, the feature is non-breaking:
530+
it silently no-ops until `romm_user_id` is stamped (see the ConnectionService lazy-identity note), then activates — no
531+
re-login required.
516532

517533
**Incremental skip — the per-platform completion stamp is the sole authority.** A platform unit skips only when its
518534
`PlatformSyncState` stamp exists
@@ -992,6 +1008,21 @@ restored) keeps the still-current server's id. `ensure_device_registered` never
9921008
permission-degraded (403/timeout) session leaves the identity intact. This is Phase 0a of the RomM Device Sync negotiate
9931009
adoption ([ADR-0016](../adr/0016-save-sync-hands-detection-to-romm-negotiate.md)).
9941010

1011+
**The signed-in user's own id (`romm_user_id`) is bound to the token — stamped at sign-in, backfilled lazily, cleared on
1012+
sign-out.** It drives the collection owner-scope filter (`build_work_queue` + `get_collections`, described in the
1013+
LibraryService section above). Every sign-in path re-derives it from the freshly authenticated token so it can never
1014+
linger for a different user or a different server: the mint path (`establish_token`) probes `GET /api/users/me` after
1015+
host-binding the minted token (the mint response carries only the token id, not the user id); the pasted/paired paths
1016+
reuse the id from the `/api/users/me` validation probe they already run (no second call). In every case the id is set
1017+
**in memory before** the token-persist save, so it rides the same single atomic `save_settings()` — the sign-in write
1018+
shape is unchanged. It is part of the snapshot/restore auth-state set, so a failed sign-in restores the previous id, and
1019+
it is cleared in each path's in-memory auth clear so a probe failure or a malformed payload leaves it `None` rather than
1020+
stale — degrading the "Own" filter to "All" until the next backfill. Existing installs (a valid token minted before this
1021+
setting existed) carry no id; `test_connection` **lazily backfills** it — when a token is present and the id is missing,
1022+
the successful connection check probes `/api/users/me` and persists the id (its own save), so the filter activates
1023+
without a re-login. A known id needs no network on later checks. Every identity read is best-effort: a failure never
1024+
fails the sign-in or the connection check.
1025+
9951026
The no-sign-in URL change path (`SettingsService.save_server_url`) deliberately does not touch the token, so pointing
9961027
the URL at a different origin leaves the stored token's origin mismatched and the auth-header guard makes subsequent
9971028
data flows fail fast with `config_error` until the user signs in again. Because every data flow (including device

docs/user-guide/syncing-your-library.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,22 @@ collections, or use the paired **Enable All** / **Disable All** buttons to bulk-
229229
**Show collection games in platform groups** toggle controls whether games pulled in via a collection also get added to
230230
their platform's Steam group.
231231

232+
#### Own / All
233+
234+
On a **shared RomM server** the collection list includes every other user's _public_ collections alongside your own. The
235+
**Show collections** control at the top of the Collections page lets you narrow that down:
236+
237+
- **All** (default) — every collection the server lists, including other users' public ones. This is the original
238+
behaviour.
239+
- **Own** — only the collections you own. Foreign collections are hidden from the sub-tabs and are excluded from the
240+
sync, even if one was enabled earlier — switching back to **All** brings your earlier choices back, since the scope
241+
filters over your enable state rather than changing it.
242+
243+
**Franchise collections always sync** under either setting: they are auto-generated groupings that belong to no one, so
244+
"Own" never hides them. The filter only becomes active once the plugin knows your account identity, which it learns the
245+
first time you sign in (existing sign-ins pick it up on the next connection check). Until then, **Own** behaves exactly
246+
like **All** — it never hides a collection it can't yet attribute.
247+
232248
If two enabled collections share the same name — for example a personal collection and a smart or franchise collection
233249
called the same thing, or (on a shared server) another account's public collection — they **merge into a single Steam
234250
collection** carrying the combined set of games. RomM allows collections to share a name, but Steam identifies a

main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,9 @@ async def set_all_collections_sync(self, enabled, scope=None):
339339
async def save_collection_platform_groups(self, enabled):
340340
return self._settings_service.save_collection_platform_groups(enabled)
341341

342+
async def set_collection_owner_scope(self, scope):
343+
return self._settings_service.set_collection_owner_scope(scope)
344+
342345
@migration_blocked
343346
async def start_sync(self):
344347
return self._sync_service.start_sync()

py_modules/adapters/persistence.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,19 @@ def time(self) -> float:
4141
"romm_api_token_id": None,
4242
"romm_api_token_origin": None,
4343
"romm_api_token_source": None,
44+
# The signed-in RomM user's own id (``/api/users/me`` ``id``), or ``None``
45+
# when identity is not yet known. Bound to the token like the device id —
46+
# stamped at sign-in, backfilled lazily on a connection check, cleared on
47+
# sign-out. Drives the collection owner-scope filter; ``None`` makes "Own"
48+
# behave like "All" (the non-breaking fallback).
49+
"romm_user_id": None,
4450
"enabled_platforms": {},
4551
"enabled_collections": {"user": {}, "smart": {}, "franchise": {}},
4652
"collection_create_platform_groups": False,
53+
# QAM collection owner-scope: ``"all"`` (default — every collection the
54+
# server lists) or ``"own"`` (only the signed-in user's own collections;
55+
# franchise/virtual collections have no owner and always survive).
56+
"collection_owner_scope": "all",
4757
"preferred_region": "auto",
4858
"steam_input_mode": "default",
4959
"steamgriddb_api_key": "",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Owner-scope classification for RomM collections.
2+
3+
The pure predicate behind the QAM "Own / All" collection filter. RomM's
4+
collection listings return the signed-in user's own collections plus every
5+
other user's PUBLIC collection; "Own" hides the foreign ones. This module owns
6+
the single rule that decides whether a collection counts as the user's own —
7+
used both to tag the frontend list (``is_own``) and to drop foreign units from
8+
the sync work queue. No I/O, no state: identity and the collection's owner id
9+
come in as arguments.
10+
11+
Two invariants make the filter safe and non-breaking:
12+
13+
* **Franchise/virtual collections have no owner.** They are global/derived —
14+
RomM's ``VirtualCollection`` model carries no ``user_id`` column and returns
15+
them identically to every user — so they are always own and always survive
16+
an "Own" filter.
17+
* **Unknown identity degrades to "All".** When the plugin does not yet know its
18+
own user id (never fetched / offline), every collection is treated as own, so
19+
"Own" filters nothing rather than filtering against the wrong identity.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
25+
def is_own_collection(collection_user_id: object, own_user_id: int | None, *, kind: str) -> bool:
26+
"""Whether a collection is the signed-in user's own (survives an "Own" filter).
27+
28+
Parameters
29+
----------
30+
collection_user_id:
31+
The collection's owner id (``user_id``) from the RomM listing dict, or
32+
``None``/absent. Compared by value against *own_user_id*. Only user and
33+
smart collections carry it; franchise/virtual collections never do.
34+
own_user_id:
35+
The signed-in user's own id (``settings["romm_user_id"]``), or ``None``
36+
when identity is not yet known.
37+
kind:
38+
``"user"``, ``"smart"`` or ``"franchise"``.
39+
40+
Returns ``True`` (own) when the collection is a franchise/virtual collection
41+
(no owner), when our own identity is unknown (the non-breaking fallback), or
42+
when the collection's owner id equals ours. ``False`` (foreign) only when a
43+
user/smart collection is owned by a different known id.
44+
"""
45+
if kind == "franchise":
46+
return True
47+
if own_user_id is None:
48+
return True
49+
return collection_user_id == own_user_id

py_modules/services/connection.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)