Visibility-coverage gaps (404-mask hidden entities, mirroring the existing
delete/read paths):
- update_rom (PUT /roms/{id}) and update_rom_user (PUT /roms/{id}/props)
- add_firmware: platform-hide now cascades to firmware uploads
- patch_rom: resolve the parent rom of both the base and patch files and
404 when hidden, so a hidden rom's bytes can no longer be streamed back
- activity feeds (get_all_activity / get_rom_activity): drop sessions whose
rom is hidden from the caller
Migration: make the role enum -> varchar narrowing Postgres-safe. The cast
now uses postgresql_using, the orphaned native role type is dropped on
upgrade, and downgrade recreates it explicitly (create_type=False) before
re-typing the column. Verified up/down/up on Postgres 16 and MariaDB.
Also collapses the two permission migrations into a single 0092 and notes
the override own_only replacement granularity limit in the resolver.
AI assistance: implemented with Claude Code (review-fix pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolves the CI blocker and a cluster of opt-out visibility "fail-open"
gaps surfaced in review of the granular permission system.
Security / correctness:
- admin oauth_scopes projection keeps canonical FULL_SCOPES order
(order_scopes) instead of sorting alphabetically, fixing the red
test_user.py::test_admin on MariaDB + Postgres.
- default-group hides no longer fail open: the resolver resolves the
effective (own-or-default) group before the hidden-entity lookup.
- /roms/by-hash and /roms/by-metadata-provider now 404-mask hidden roms.
- USERS-entity grant no longer enables admin creation: add_user and
invite-link require a real admin to mint admin accounts.
Visibility leaks closed on secondary read paths:
- feeds, sibling roms (list query + single-rom schemas), /stats counts
and per-platform breakdowns, collection rom_ids/rom_count, search_rom.
Hardening / cleanups:
- firmware/platform PUT 404-mask hidden entities; group rename conflict
returns 400 not 500; guard against removing the last default group;
kiosk read-only enforced at the fine layer; add_hidden_entity rejects
non-cascading entity types.
Frontend:
- permissionGroups.ensureLoaded coalesces concurrent callers on one
in-flight request; permissions.setGrants resets isAdmin/hidden;
CreateUserDialog no longer orphans a user when group assignment fails;
HiddenGamesPicker search rows are native buttons (keyboard/gamepad);
invite-role labels and group swatch aria-label use i18n; drop dead code
(originalRole, unused permissionsApi export).
AI assistance: changes authored with Claude Code (Claude Opus), driven by
the Copilot review and a multi-agent adversarial review, then verified
(backend pytest, frontend typecheck/vitest, i18n parity, trunk).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compute the "new platforms" totals from the platforms already loaded via
get_platforms() instead of issuing one get_platform_by_fs_slug query per
platform. Fix the test fixture to report the existing platform through
get_platforms() so the mocked data matches the code path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compute the "new platforms" totals from the same existence check used
per-platform in _identify_platform, so the tracker totals match what is
actually scanned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The patcher uploads the patched ROM and then fires a platform scan to
register it. When a second scan runs against the same platform around the
same time (a filesystem-watcher rescan, a scheduled rescan, or another
manual scan on a multi-worker setup), both scans could see the new file as
absent from the DB and each insert it, producing two identical library
entries for one patched file.
A platform folder can't physically hold two entries with the same name, so
a ROM is uniquely identified by (platform_id, fs_name). Enforce that with a
unique index instead of the previous plain index, which makes the duplicate
impossible. The scan's early ROM insert now adopts the row created by a
concurrent scan (catching the integrity error and skipping) instead of
failing, and ROM rename pre-checks for a name collision so it returns a
clean 409 rather than hitting the constraint.
Includes a migration that removes any pre-existing duplicates (keeping the
lowest id; dependents cascade) before upgrading the index to unique.
Fixes#3590
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0135UV8Xn2XHkRhjzhm9UptP
The scan tracker computed total_platforms and total_roms over every
filesystem platform, ignoring both the selected platforms and the scan
type. For a "new platforms" scan, existing platforms are skipped inside
_identify_platform, so their ROMs never count toward scanned_roms, yet
they were all included in total_roms. This made the tracker wildly
overcount (the whole library instead of just the new platform).
Resolve the platform list before computing totals and, for NEW_PLATFORMS
scans, exclude platforms that already exist in the database so the totals
match what is actually processed.
Fixes#3599
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158RJnc7MmAbwRz6Qiyrj5a
Collapse the Role enum from viewer/editor/admin to two kinds (admin/user);
non-admin access now comes entirely from permission groups + overrides.
Backend:
- Role -> StrEnum {USER, ADMIN}, VARCHAR-backed (native_enum=False); Role.coerce
maps legacy/unknown strings (incl. in-flight invites) to USER.
- Resolver: group-less users fall back to the default group (dropped the
role-based legacy fallback); kiosk caps all non-admins to read-only.
- OIDC editor/viewer claims both map to USER (env var names unchanged).
- Migration 0092 converts the native enum to VARCHAR and normalizes
ADMIN->admin, VIEWER/EDITOR->user.
- Updated endpoints, conftest fixtures, role/oidc/parity/db-handler tests, tools.
Frontend:
- v2 + v1 user management now use admin/user (dropdowns, defaults, getRoleIcon).
- Regenerated types (Role = 'user' | 'admin'); added role-user i18n to all locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removing the aspect_ratio body field left update_platform with a single
scalar Body() param. FastAPI stops embedding a lone scalar body, so the
endpoint began expecting a bare JSON string while the frontend keeps
sending {"custom_name": "..."}, producing a 422 when editing a
platform's display name in v2.
Restore the embedded-key contract with Body(embed=True), matching the
frontend payload and every sibling update endpoint. Regenerate the
frontend types (restores the Body_update_platform model) and add an
endpoint regression test.
AI assistance: written with Claude Code (Opus 4.8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The char index and rom id index sidecars are cached under a key that
encodes only user/order/grouping. is_unscoped previously excluded only
scope and search, so metadata/tag/status filters and the bool flags
applied to the query bypassed the gate: a filtered all-games request
stored a narrowed id list under the shared "all" key and later
unfiltered (or differently-filtered) requests read it back, showing the
wrong set and count of games.
Treat any narrowing parameter as scoped so those sets compute live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deduplicate the identical cache key expression used by the char index,
filter values, and rom id index sidecars so the key scheme stays
consistent across them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /api/roms list endpoint did several O(library) computations on every
request. On a 100k-rom library each request took 4-5s. This addresses the
dominant costs, all measured on a real 100k-rom MariaDB.
- Cache rom_id_index: the full ordered id list backing virtual scroll was
recomputed (the sibling-dedup window over the whole library) on every
request, even limit=1, and shipped uncached. Memoise the unscoped scan
under the same versioned cache as the other sidecars. 2815ms -> 7ms on hit.
- Slim the sibling-dedup query: the inner derived table materialized all of
Rom (including JSON metadata blobs) for 100k rows, and carried a wide unused
fs_name_no_ext through the window's temp table (spilling the sort to disk),
plus a pointless inner ORDER BY. Select only the columns the window needs.
2.79s -> 0.86s, identical results, no schema change.
- Rewrite with_char_index: replace row_number() over the whole library (full
materialization + double filesort) with a per-letter COUNT and an
accumulate. Identical output, drops a filesort layer.
- Add idx_roms_sibling_cover covering index for the sibling_roms view
self-join, so the 7-way metadata-id OR resolves from the index instead of
reading wide rows per parent. ~8x on dense pages warm, far more cold.
AI assistance: written with Claude Code (diagnosis, query rewrites, migration,
tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Added new permission handling in `backend/handler/auth/dependencies.py` to support fine-grained, DB-backed permission checks.
- Enhanced user role update logic in `backend/endpoints/user.py` to prevent demotion of the last admin.
- Introduced `hidden_platform_ids` and `hidden_rom_ids` parameters in various database handlers to manage visibility based on admin settings.
- Created new endpoints for managing permission groups, user memberships, and hidden entities in `backend/tests/endpoints/test_permissions_admin.py`.
- Added tests for permissions visibility and CRUD operations in `backend/tests/endpoints/test_permissions_visibility.py` and `backend/tests/endpoints/test_permissions_me.py`.
- Updated archive handling in `backend/utils/archives.py` to improve error logging and timeout management during extraction.
- patcher.js resolves rom-patcher-js from both the relocated sibling
layout (docker/Dockerfile) and the plain node_modules layout (root
Dockerfile), so both build flows work without a manual copy
- apply_patch wraps the node subprocess in asyncio.wait_for with a
timeout and kills it on expiry; a semaphore bounds concurrency, and the
endpoint rejects oversized ROM/patch files to avoid OOM
- report the patch source-checksum validation result via an
X-Patch-Validated header; the patcher UI warns on a mismatch
- return a generic "Patching failed" detail to clients and log the real
error server-side, so node/RomPatcher.js paths don't leak
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Move default_category_for_non_nested validator onto RomFileSchema so
top-level files default to category "game" (the v2 patcher's base-file
filter relies on this).
- Use Annotated Body() in the patch endpoint; check patcher output via
anyio async Path.
- Drop the now-unused client-side rom-patcher and vite-plugin-static-copy
(patching is server-side); simplify the Storybook plugin filter.
- Regenerate frontend OpenAPI types.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Show a "where you left off" screenshot on the Home continue-playing rail and
the live-activity board, with a small cover-art thumbnail (PIP) in the corner
so the game stays identifiable. Both render at the image's natural aspect.
Backend:
- New shared util `continue_playing_screenshot(rom, latest_save)` resolving the
image in priority order: latest save's screenshot, then title screen, then
first gameplay screenshot (None → frontend falls back to cover art).
- `SimpleRomSchema.screenshot_path` populated only on the `last_played` query;
`get_latest_saves_for_roms` batch handler (+ tests).
- ActivityEntry / ActivityEntrySchema gain `screenshot_path`, computed from the
session player's latest save in both the socket and REST heartbeat paths.
Frontend:
- New shared `CoverArtPip.vue` (bottom-right 2D cover thumbnail), reused by
GameCard and ActivityCard.
- Home continue-playing rail uses `screenshot_path` + PIP, natural aspect (no
forced hero/style).
- Activity board: screenshot-forward cover + PIP, and a wrapping flex layout so
cards share a uniform height with natural-ratio widths (gallery-card
behavior).
- GameCover only keys the measured ratio by rom id for the rom's own cover, so
a `coverSrc` override (screenshot) never pollutes the gallery's ratio cache.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The platform aspect_ratio setting is dropped from the UI and the API
(platform update body + response schema) — nothing consumed it for
rendering, and covers now size to their image's natural aspect.
- SettingsTab: remove the cover-style / aspect-ratio picker (and its
now-dead helpers, CSS, and unused imports); collapse to a single column.
- update_platform: drop the `aspect_ratio` body field; PlatformSchema no
longer returns it; utils/platforms stops seeding the default.
- Regenerate the affected frontend types (PlatformSchema, update body).
The DB column stays (out of the update/response scope; dropping it would
be a separate destructive migration) but is no longer read or written
through the API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend:
- Resolve the acting user from the authenticated socket session on
connect instead of trusting the client-supplied user_id, so a client
can no longer spoof a "now playing" session for another user. Only
rom_id/device_id come from the payload.
- Emit activity:update/clear through the already-initialised socket
server instead of opening (and leaking) a fresh AsyncRedisManager per
REST heartbeat.
- Collapse get_all_active's per-key GET into a single MGET.
- Drop the pure pass-through _build_activity_entry helper.
Frontend:
- Remove all activity emits from the v1 EmulatorJS Player; the v2 shell
is the single driver of the activity lifecycle.
- Remove activity from the v1 UI entirely (Activity view, ActivityBtn,
ActivePlayers on game details, navigation, and the now-v2-only route).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Introduced new API endpoints for updating visibility of saves and states.
- Added `is_public` property to `SaveSchema` and `StateSchema`.
- Created new models for user saves and states with visibility attributes.
- Updated the `SaveDataTab` component to differentiate between "Mine" and "Community" sections.
- Implemented visibility toggle functionality for user saves and states.
- Enhanced localization files to include new strings for visibility actions.
- device/init returns a relative verification_path; the client joins it
with its own origin
- Render the v2 approval screen via the named v2 router outlet (was blank)
- DevicePair: RSpinner, keyboard-accessible scope chips, scrollable scopes;
DevicePairShell uses always-light overlay tokens (no hex)
Instead of always inferring "web" for browser-emitted activity events, look
the device up by device_id and use its client type, falling back to "web"
(the browser default) when no device record exists. Mirrors the REST
heartbeat endpoint, which derives device_type from device.client.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- main.py: await the cancelled log-forwarder task (suppressing
CancelledError) so its pubsub/lock cleanup finishes before shutdown.
- forwarder: only heartbeat the Redis lock while we still own it; if a
stall let another worker take it, relinquish forwarding to avoid
duplicate lines (the outer loop re-contends).
- endpoints/logs.py: derive MAX_LOG_LIMIT from LOG_BUFFER_SIZE so the
REST backfill never drifts from the producer's ring buffer.
- Logs.vue: append the download <a> to the DOM before click() (matches
the Patcher pattern) for cross-browser reliability.
- Add tests/endpoints/test_logs.py: non-admin 403, limit clamping to
[1, MAX_LOG_LIMIT], oldest-first ordering, and malformed-entry skip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The log-stream handler and forwarder deliberately swallow exceptions: the
handler is a best-effort mirror that must never raise into the app, and the
forwarder can't log its own failures without feeding back into the stream.
Annotate these with `# nosec` (with justification) so Trunk's bandit check
passes, keeping lines within black's width.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Introduced a new logs view for admin users, allowing real-time monitoring of backend logs.
- Implemented a log entry streaming mechanism using Socket.IO.
- Added filtering and searching capabilities for log entries.
- Created localized log messages in Spanish, French, Hungarian, Italian, Japanese, Korean, Polish, Portuguese, Romanian, Russian, Simplified Chinese, and Traditional Chinese.
- Updated router and sidebar components to include the new logs route.
- Enhanced user interface with tooltips and buttons for copying and downloading logs.
Drop the name_sort_key_custom flag/migration in favour of a flagless rule: a
key is "custom" when it no longer equals compute(name). Apply that consistently
across all three write paths so a manual sort key survives renames while a
derived key keeps following the name:
- @validates re-derives on name assignment only when the stored key still
matches the derived value; direct name_sort_key assignment stores a
normalized custom key (or reverts to derived when cleared). Handles both
kwarg orders at construction.
- update_rom mirrors the same check for the bulk update() path it bypasses.
- The edit endpoint only writes the key when the user actually changed the
field, delegating the untouched case to update_rom.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the separate `sort_name` column into `name_sort_key`, which is now
the single user-settable sort field: always normalized and indexed for fast
ordering, derived from `name` by default, and overridable. A new
`name_sort_key_custom` boolean marks user/metadata overrides so they survive
renames and rescans.
- Drop the `roms.sort_name` column; repurpose migration 0085 to add
`name_sort_key_custom`.
- Derive the key via `@validates("name")` unless pinned custom; the edit
dialog, unmatch flow, and ES-DE gamelist <sortname> set custom keys.
- update_rom / scan_rom keep the columns in sync explicitly (bulk update and
construction bypass / reorder the validator).
- Frontend: edit field drives name_sort_key (empty when auto), api sends the
override only when custom, regenerated types updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>