diff --git a/.dockerignore b/.dockerignore index aedde1af9..ae51a2bb8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -70,6 +70,7 @@ docs/_private/ # Operator BIOS staging from scripts/import_bios.py (never bake dumps into the image) .bios-install-out/ +.bios-install-out-*/ bios/ **/bios/*.bin **/bios/*.rom diff --git a/.env.docker.example b/.env.docker.example index 5cc235d68..2e4d12d25 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -62,6 +62,7 @@ REMEMBER_COOKIE_SECURE=true CHAT_ATTACHMENT_STORAGE_MAX_BYTES=0 CHAT_ATTACHMENT_STORAGE_MAX_FILES=0 TRUSTED_PROXIES=0 +TRUSTED_PROXY_CIDRS= # Hosts allowed to become the origin of an emailed link (password reset, invite). # Empty trusts private/LAN hosts only, so http://192.168.x.x links keep working diff --git a/.env.example b/.env.example index fc6a570ba..5dc09a43b 100644 --- a/.env.example +++ b/.env.example @@ -117,6 +117,9 @@ OIDC_DISPLAY_NAME=Sign in with SSO SESSION_COOKIE_SECURE=true REMEMBER_COOKIE_SECURE=true TRUSTED_PROXIES=0 +# Only peers in these addresses/CIDRs may supply forwarded headers. Example: +# TRUSTED_PROXY_CIDRS=172.20.0.5/32,172.20.0.0/24 +TRUSTED_PROXY_CIDRS= # Hosts allowed to become the origin of an emailed link (password reset, invite). # Leave empty on a LAN install: private/loopback hosts are trusted by default, so diff --git a/.env.nas.example b/.env.nas.example index 887ba0cb0..e3cf481f3 100644 --- a/.env.nas.example +++ b/.env.nas.example @@ -60,6 +60,7 @@ REMEMBER_COOKIE_SECURE=true CHAT_ATTACHMENT_STORAGE_MAX_BYTES=0 CHAT_ATTACHMENT_STORAGE_MAX_FILES=0 TRUSTED_PROXIES=0 +TRUSTED_PROXY_CIDRS= # Hosts allowed to become the origin of an emailed link (password reset, invite). # Empty trusts private/LAN hosts only, so http://192.168.x.x links keep working diff --git a/.env.single.example b/.env.single.example index 25f364840..0fecc6c5b 100644 --- a/.env.single.example +++ b/.env.single.example @@ -19,3 +19,7 @@ TZ=UTC # Use an external Postgres instead of the embedded one: # set DATABASE_URL=postgresql://user:pass@host:5432/oneirodex # Force the embedded one even if DATABASE_URL is set: ONEIRODEX_EMBEDDED_DB=true +# Optional reverse-proxy trust. Forwarded headers are honored only when the +# immediate proxy source also matches TRUSTED_PROXY_CIDRS. +TRUSTED_PROXIES=0 +TRUSTED_PROXY_CIDRS= diff --git a/.env.unraid.example b/.env.unraid.example index 98378551a..991d582db 100644 --- a/.env.unraid.example +++ b/.env.unraid.example @@ -99,6 +99,7 @@ REMEMBER_COOKIE_SECURE=true CHAT_ATTACHMENT_STORAGE_MAX_BYTES=0 CHAT_ATTACHMENT_STORAGE_MAX_FILES=0 TRUSTED_PROXIES=0 +TRUSTED_PROXY_CIDRS= # Hosts allowed to become the origin of an emailed link (password reset, invite). # Empty trusts private/LAN hosts only, so http://192.168.x.x links keep working diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 35575e372..76aaeca4e 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -1,4 +1,4 @@ -# Oneirodex CI — PR gate with a *core* pytest subset (+ member-app / desktop vitest). +# Oneirodex CI — PR gate with the pytest marker set (+ member-app / desktop vitest). # # Full pytest suite remains local / release-time (needs Unraid-like fixtures, # library paths, and heavier integrations). Do not treat this workflow as @@ -31,7 +31,7 @@ jobs: pytest-core: name: Pytest (core) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 35 services: postgres: @@ -73,6 +73,28 @@ jobs: # branch red with no repo change behind it. pip install -r requirements-dev.txt + - name: Set up Node.js 22 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '22' + cache: npm + cache-dependency-path: package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Build React shells required by Python route tests + run: | + npm run build --workspace=member-app + npm run build:auth-pilot --workspace=member-app + npm run build --workspace=admin-app + npm run build:pilot --workspace=admin-app + + - name: Verify store sync transaction invariants + # Keep this SQLite compatibility slice ahead of the 20+ minute core + # suite so transaction regressions fail quickly and with a focused log. + run: python -m pytest tests/test_store_connections.py -q + - name: Run pytest (marker set, deselect by exception) # Gate = every test not marked `integration`, minus a named exception # list. Flipped from a hand-enumerated file list on 2026-09-17 after @@ -81,8 +103,8 @@ jobs: # default; the only way to keep one out is a line below, with its ID. # # The --deselect list is docs/dev/archive/test-suite-failures-2026-09-16.md's - # known failures as they reproduce on the runner (three are runner- - # only: the SPA dist is not built in this job, a Windows drive-letter + # known failures as they reproduce on the runner (two are runner- + # only: a Windows drive-letter # case, the font catalogue). Remove a line when its test is fixed; # never add one to get a red PR green. # @@ -92,14 +114,12 @@ jobs: python -m pytest tests/ -q -m "not integration" --cov=oneirodex --cov-report=term-missing --cov-fail-under=65 - --deselect tests/test_admin_spa_assets.py::test_admin_spa_dist_built --deselect tests/test_checkout_paths.py::test_operator_docs_checkout_is_oneirodex --deselect tests/test_collections_api_wiring.py::test_newsletter_ckeditor_targets_content_field --deselect tests/test_collections_api_wiring.py::test_spa_collections_client_covers_new_actions --deselect tests/test_library_roots.py::TestParseLibraryRoots::test_windows_drive_letter_is_not_mistaken_for_a_label --deselect tests/test_member_chrome_css.py::test_tile_hover_has_no_accent_glow_and_grows_from_center --deselect tests/test_member_chrome_css.py::test_topbar_cluster_is_not_a_shared_outline - --deselect tests/test_member_spa_assets.py::test_member_spa_links_member_app_css --deselect tests/test_routes_admin_ext_settings_shell.py::TestSettingsShellSections::test_every_section_deep_link_is_reachable_from_the_react_hub --deselect tests/test_routes_apis_download.py::TestDeleteDownloadRequest::test_delete_invalid_request_id_zero --deselect tests/test_routes_apis_download.py::TestDeleteDownloadRequest::test_delete_request_database_error diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c87b1410..77583df0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.9] — 2026-10-15 + +### Improved +- Unify member and admin navigation, controls, tables, filters and shared icon/theme primitives across the React applications. +- Complete the static React and native FastAPI runtime transition, removing Jinja-rendered application pages while retaining existing session compatibility. +- Scrub Docker build context of user BIOS staging output so firmware and key files cannot enter shipped image layers. + +### Security +- Authenticate forwarded client and origin headers against `TRUSTED_PROXY_CIDRS` before applying `TRUSTED_PROXIES` hop parsing. Supported launchers disable Uvicorn's implicit rewriting. Reverse-proxy deployments must configure the immediate proxy source IP/CIDR. +- Bind desktop path reveals to an accessible game folder; require admin authority for unmatched and unbound scan-job paths. +- Revalidate qBittorrent hardlink pipeline redirects and outbound destinations through the shared safe-request policy. + ## [1.1.8] — 2026-10-08 ### Improved diff --git a/Dockerfile b/Dockerfile index 748f885ff..4b759af0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,9 +39,11 @@ COPY oneirodex/setup/default_theme/js/scanJobsDom.js oneirodex/setup/default_the # vite writes each bundle to oneirodex/static/dist// (outDir is resolved # from the app dir in its vite.config); `npm run build` per workspace runs the # app's `tsc --noEmit && vite build`. -RUN mkdir -p oneirodex/static/dist/member-app oneirodex/static/dist/admin-app oneirodex/static/dist/ops-glance \ +RUN mkdir -p oneirodex/static/dist/member-app oneirodex/static/dist/admin-app oneirodex/static/dist/admin-pilot oneirodex/static/dist/ops-glance \ && npm run build --workspace=member-app \ + && npm run build:auth-pilot --workspace=member-app \ && npm run build --workspace=admin-app \ + && npm run build:pilot --workspace=admin-app \ && npm run build --workspace=ops-glance FROM python:3.12-slim @@ -89,7 +91,9 @@ RUN mkdir -p /etc/postgresql-common \ COPY . . COPY --from=frontend-build /build/oneirodex/static/dist/member-app /app/oneirodex/static/dist/member-app +COPY --from=frontend-build /build/oneirodex/static/dist/member-auth /app/oneirodex/static/dist/member-auth COPY --from=frontend-build /build/oneirodex/static/dist/admin-app /app/oneirodex/static/dist/admin-app +COPY --from=frontend-build /build/oneirodex/static/dist/admin-pilot /app/oneirodex/static/dist/admin-pilot COPY --from=frontend-build /build/oneirodex/static/dist/ops-glance /app/oneirodex/static/dist/ops-glance RUN pip install -r requirements.txt diff --git a/README.md b/README.md index 5f9322e54..61c683b38 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 1.1.8 + Version 1.1.9 GitHub Port 5006 Docker Compose @@ -39,14 +39,14 @@ 01 · Overview — What is Oneirodex? -Oneirodex is a **Flask + React** server you run at home or on a NAS. Point it at folders of DRM-free games and ROMs, let it identify them against IGDB / Steam / GOG / RAWG, then give the household a modern browser app to **browse, download, play in the browser where a system supports it, and hang out** — with a desktop companion for PC titles and a ten-foot mode for the TV. +Oneirodex is a **FastAPI + React** server you run at home or on a NAS. Point it at folders of DRM-free games and ROMs, let it identify them against IGDB / Steam / GOG / RAWG, then give the household a modern browser app to **browse, download, play in the browser where a system supports it, and hang out** — with a desktop companion for PC titles and a ten-foot mode for the TV. Every screenshot and clip on this page is **live UI** from a stock install seeded with five legal free ROMs: what you see is what ships. - - - + + +
🏷️ Release1.1.8 · VERSION🌐 Default URLhttp://localhost:5006
🐳 Containersoneirodex-app · oneirodex-db · optional oneirodex-livekit🖼️ Imagelocal Compose build oneirodex:1.1.8
📦 Packageoneirodex/ — Flask under ASGI (uvicorn)🧩 Front endsmember · admin · ops-glance SPAs, Tauri desktop, Quest PWA
🏷️ Release1.1.9 · VERSION🌐 Default URLhttp://localhost:5006
🐳 Containersoneirodex-app · oneirodex-db · optional oneirodex-livekit🖼️ Imagelocal Compose build oneirodex:1.1.9
📦 Packageoneirodex/ — FastAPI under uvicorn🧩 Front endsmember · admin · ops-glance SPAs, Tauri desktop, Quest PWA
> **Legal.** Use Oneirodex only with software you are authorised to share. It ships **no** Discord bots, pirate marketplaces or store download queues. **Authentik / OIDC is optional** — local username and password is the default for a home install. @@ -364,12 +364,12 @@ Full lists: [`.env.example`](.env.example) · [`.env.docker.example`](.env.docke ```text ┌─────────────────┐ ┌──────────────────┐ ┌────────────┐ ┌────────────┐ │ Member SPA │ │ Admin SPA │ │ Desktop │ │ Quest PWA │ -│ (React) │ │ (React + Jinja) │ │ companion │ │ (/vr) │ +│ (React) │ │ (React) │ │ companion │ │ (/vr) │ └────────┬────────┘ └────────┬─────────┘ └─────┬──────┘ └─────┬──────┘ └─────────────┬───────┴───────────────────┴────────────────┘ ▼ ┌─────────────────┐ - │ Flask on ASGI │ ← oneirodex/ :5006 + │ FastAPI / ASGI │ ← oneirodex/ :5006 │ APIs · auth │ └───────┬─────────┘ ┌───────────┼────────────┐ @@ -391,13 +391,18 @@ Full lists: [`.env.example`](.env.example) · [`.env.docker.example`](.env.docke | Area | What Oneirodex uses | |---|---| -| Language / server | **Python 3.12**, **Flask** under ASGI (uvicorn), SQLAlchemy + Alembic migrations | -| Front ends | **React 19 + Vite + TypeScript** single-page apps (member, admin, ops-glance); admin also uses Jinja templates | +| Language / server | **Python 3.12**, **FastAPI** (uvicorn), SQLAlchemy + Alembic; Flask compatibility tools are development-only | +| Front ends | **React 19 + Vite + TypeScript** single-page apps (member, admin, ops-glance); no application-authored Jinja templates remain | | Desktop / VR | **Tauri** desktop companion · Quest PWA | | Database | **PostgreSQL 17** — the only required service | | Node.js | Build-time only (the Docker image compiles the SPAs, then ships Python); not a runtime dependency | | Not used | Next.js, PHP, Django, MySQL/MariaDB | +The admin and member interfaces are React single-page apps. Application-authored +Jinja templates and the WSGI bridge have been retired. FastAPI owns request +dispatch; the runtime requirements exclude Flask, its extensions, and Jinja. +Legacy Flask route modules remain available to development and parity tests. + **Default ports:** `5006` (web app) and `5432` (PostgreSQL, internal to Compose). **Optional services** — off by default, enabled by env vars or Compose profiles; none are needed for a normal install: diff --git a/VERSION b/VERSION index 18efdb9ae..512a1faa6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.8 +1.1.9 diff --git a/alembic/env.py b/alembic/env.py index 1b4b12c3e..4ce2c51c8 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -56,12 +56,14 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) -# Importing the models module registers every table on db.metadata. -from oneirodex import db # noqa: E402 +# Importing the framework-independent models module registers every table on +# the shared declarative metadata. Alembic runs in the production image, where +# the legacy Flask compatibility runtime (and its Jinja dependency) is absent. from oneirodex import models # noqa: E402,F401 (import for side effect) +from oneirodex.models.orm import ModelBase # noqa: E402 from config import Config # noqa: E402 -target_metadata = db.metadata +target_metadata = ModelBase.metadata # Indexes the pre-Alembic raw-DDL pass (oneirodex/updateschema.py) created that # are NOT re-declared on models.py metadata. The baseline revision builds them diff --git a/asgi.py b/asgi.py index b3453ca9f..be8f96ef3 100644 --- a/asgi.py +++ b/asgi.py @@ -1,21 +1,25 @@ -""" -ASGI config for Oneirodex production deployment. -This file wraps the Flask app to be compatible with ASGI servers like uvicorn -and provides async file streaming for downloads and static assets. - -The WSGI bridge is a2wsgi, not asgiref. asgiref's ``WsgiToAsgi`` submits the -call into a ``CurrentThreadExecutor`` that can already be shut down when the -client goes away mid-request, so a cancelled request died as a 500 the -operator then had to explain (UID-052). a2wsgi runs the WSGI app on its own -thread pool and treats ``http.disconnect`` as a disconnect rather than a -broken executor. - -Static assets and SSE still bypass the bridge, but not for that reason: see -``_handle_static`` and ``_handle_sse``. +"""ASGI runtime for Oneirodex. + +FastAPI owns application route dispatch. Flask still initializes the shared +configuration, extension registry, and mapped models during this migration; +the WSGI fallback has been retired after the legacy method/path inventory +reached full native coverage. """ from __future__ import annotations +from oneirodex.runtime_native import ( + is_admin_redirect_path, + is_email_confirmation_page_route, + is_game_edit_route, + is_game_image_refresh_route, + is_game_images_edit_route, + is_library_edit_route, + is_local_game_image_route, + is_single_image_upload_route, + is_numeric_admin_detail, +) + import asyncio import json import mimetypes @@ -25,31 +29,84 @@ import tempfile import uuid from pathlib import Path +from importlib import import_module import aiofiles -from a2wsgi import WSGIMiddleware from werkzeug.utils import secure_filename -from oneirodex import create_app, db -from oneirodex.async_streaming import ( - async_generate_zipstream_response, - get_content_type_for_file, +from sqlalchemy import select + + +def create_app(config_object=None): + """Lazily expose the legacy app factory for compatibility startup/tests.""" + from oneirodex import create_app as legacy_create_app + + return legacy_create_app(config_object) + + +_DEFAULT_CREATE_APP = create_app + + +class _LazyDatabaseExport: + """Resolve Flask-SQLAlchemy only when a compatibility handler needs it.""" + + def __getattr__(self, name): + from oneirodex import db as legacy_db + + return getattr(legacy_db, name) + + +db = _LazyDatabaseExport() + + +def _lazy_callable(module_name, attribute): + """Create a compatibility callable without importing Flask-owned helpers.""" + def call(*args, **kwargs): + helper = getattr(import_module(module_name), attribute) + return helper(*args, **kwargs) + + call.__name__ = attribute + return call + + +async_generate_zipstream_response = _lazy_callable( + 'oneirodex.async_streaming', 'async_generate_zipstream_response' ) -from oneirodex.models import DownloadRequest, Game, User -from oneirodex.utils.api_tokens import forbidden_scopes_for_role -from oneirodex.utils.event_logging import log_system_event -from oneirodex.utils.library_acl import user_can_access_game -from oneirodex.utils.play_url import library_platform_key -from oneirodex.utils.rom_archive import ( - ArchiveRomError, - bundle_playable_rom_zip, - resolve_playable_rom_path, +get_content_type_for_file = _lazy_callable( + 'oneirodex.async_streaming', 'get_content_type_for_file' ) -from oneirodex.utils.security import get_allowed_base_directories, is_safe_path, open_plain_file_within -from oneirodex.utils.security_headers import baseline_static_headers -from oneirodex.utils.library_paths import library_dir -from oneirodex.utils.static_files import resolve_served_static, static_access -from sqlalchemy import select +forbidden_scopes_for_role = _lazy_callable( + 'oneirodex.utils.api_tokens', 'forbidden_scopes_for_role' +) +log_system_event = _lazy_callable('oneirodex.utils.event_logging', 'log_system_event') +user_can_access_game = _lazy_callable( + 'oneirodex.utils.library_acl', 'user_can_access_game' +) +library_platform_key = _lazy_callable('oneirodex.utils.play_url', 'library_platform_key') +bundle_playable_rom_zip = _lazy_callable( + 'oneirodex.utils.rom_archive', 'bundle_playable_rom_zip' +) +resolve_playable_rom_path = _lazy_callable( + 'oneirodex.utils.rom_archive', 'resolve_playable_rom_path' +) +get_allowed_base_directories = _lazy_callable( + 'oneirodex.utils.security', 'get_allowed_base_directories' +) +is_safe_path = _lazy_callable('oneirodex.utils.security', 'is_safe_path') +open_plain_file_within = _lazy_callable( + 'oneirodex.utils.security', 'open_plain_file_within' +) +baseline_static_headers = _lazy_callable( + 'oneirodex.utils.security_headers', 'baseline_static_headers' +) +library_dir = _lazy_callable('oneirodex.utils.library_paths', 'library_dir') +resolve_served_static = _lazy_callable( + 'oneirodex.utils.static_files', 'resolve_served_static' +) +resolve_static_path = _lazy_callable( + 'oneirodex.utils.static_files', 'resolve_static_path' +) +static_access = _lazy_callable('oneirodex.utils.static_files', 'static_access') #: How much of a served file each ``http.response.body`` message carries. @@ -105,76 +162,4077 @@ def _rom_location(rom_path, cache_dir, real_game, game_is_dir): return real_game, os.path.dirname(real_game), True -# Proper ASGI application with lifespan protocol support -#: A visitor cancelling a download, not a server fault. -_CLIENT_GONE = (ConnectionResetError, BrokenPipeError, ConnectionAbortedError) +# Proper ASGI application with lifespan protocol support +#: A visitor cancelling a download, not a server fault. +_CLIENT_GONE = (ConnectionResetError, BrokenPipeError, ConnectionAbortedError) + + +def _install_native_api_error_handlers(native_app, config, logger) -> None: + """Keep native API failures in the shared envelope without Flask context.""" + from fastapi import Request + from starlette.exceptions import HTTPException as StarletteHTTPException + from starlette.responses import JSONResponse, Response + + from oneirodex.utils.api_contract import api_error_payload + from oneirodex.utils.error_envelope import http_error_code + from oneirodex.utils.security_headers import security_header_values + + safe_messages = { + 400: 'Bad request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not found', + 405: 'Method not allowed', + 409: 'Conflict', + 413: 'Request payload is too large', + 422: 'Invalid request', + 429: 'Too many requests', + } + + async def http_error(_request: Request, exc: StarletteHTTPException): + status = int(exc.status_code or 500) + payload, _ = api_error_payload( + safe_messages.get(status, 'Request failed'), + code=http_error_code(status), + status=status, + ) + response = JSONResponse(payload, status_code=status) + for key, value in security_header_values(config).items(): + response.headers.setdefault(key, value) + for key, value in (getattr(exc, 'headers', None) or {}).items(): + response.headers.setdefault(key, value) + return response + + async def unexpected_error(_request: Request, _exc: Exception): + logger.exception('Unhandled native API request failure') + payload, status = api_error_payload( + 'Something went wrong on our end. The error has been logged.', + code='internal', + ) + response = JSONResponse(payload, status_code=status) + for key, value in security_header_values(config).items(): + response.headers.setdefault(key, value) + return response + + native_app.add_exception_handler(StarletteHTTPException, http_error) + native_app.add_exception_handler(Exception, unexpected_error) + + +def _is_link_or_escape_refusal(exc: OSError) -> bool: + """True when ``open_plain_file_within`` refused the file itself (a link, + not a regular file, or outside its folder), as opposed to the file being + locked, unreadable or failing with an I/O error.""" + import errno + + if exc.errno in (errno.ELOOP, errno.EINVAL): + return True + return exc.errno == errno.EACCES and exc.strerror == 'file is outside its folder' + + +def _is_native_library_api_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 3 and parts[:2] == ['api', 'library'] and bool(parts[2]) + ) or ( + len(parts) == 4 and parts[:2] == ['api', 'library'] + and bool(parts[2]) and parts[3] == 'watch' + ) or ( + len(parts) == 4 and parts[:3] == ['api', 'admin', 'libraries'] + and bool(parts[3]) and parts[3] != 'batch' + ) + + +def _is_native_library_tools_path(path: str) -> bool: + return path in { + '/api/library_tools/manual_identify/preview', + '/api/library_tools/manual_identify/list', + '/api/library_tools/propose_leaf_libraries', + '/api/library_tools/import_leaf_libraries/preview', + '/api/library_tools/proposals', + '/api/library_tools/proposals/scan_roots', + '/api/library_tools/doctor/dry_run', + '/api/library_tools/rename/preview', + '/api/library_tools/proposals/approve', + '/api/library_tools/doctor/write_proposals', + '/api/library_tools/link_existing_game', + '/api/library_tools/rename/apply', + '/api/library_tools/doctor/apply_renames', + '/api/library_tools/backfill_steam_metadata', + '/api/library_tools/check_freshness', + } + + +def _is_native_library_admin_mutation_path(path: str) -> bool: + return path in { + '/api/admin/libraries', + '/api/admin/libraries/scan', + '/api/admin/libraries/refresh_all', + '/api/admin/libraries/batch/scan', + '/api/admin/libraries/batch/delete', + } + + +def _is_native_support_reads_path(path: str) -> bool: + if path == '/api/support/tickets': + return True + parts = path.strip('/').split('/') + return ( + len(parts) == 4 and parts[:3] == ['api', 'support', 'tickets'] + and parts[3].isdigit() + ) + + +def _is_native_theme_fonts_read_path(path: str) -> bool: + return path == '/api/theme/fonts' + + +def _is_native_admin_theme_font_path(path: str, method: str) -> bool: + return ( + (path == '/admin/api/theme/fonts' and method == 'POST') + or (path.startswith('/admin/api/theme/fonts/') and method == 'DELETE') + ) + + +def _is_native_admin_art_studio_download_path(path: str, method: str) -> bool: + return path.startswith('/admin/api/art-studio/download/') and method == 'GET' + + +def _is_native_image_refresh_progress_path(path: str, method: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 2 and parts[0] == 'check_image_refresh_progress' + and bool(parts[1]) and method in ('GET', 'HEAD') + ) + + +def _is_native_library_delete_progress_path(path: str, method: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 2 and parts[0] == 'check_deletion_progress' + and bool(parts[1]) and method in ('GET', 'HEAD') + ) + + +def _is_native_library_delete_sse_path(path: str, method: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 2 and parts[0] == 'delete_library_progress' + and bool(parts[1]) and method in ('GET', 'HEAD') + ) + + +def _is_native_scan_job_mutation_path(path: str, method: str) -> bool: + if method != 'POST': + return False + if path in {'/clear_all_scan_jobs', '/clear_scan_jobs'}: + return True + parts = path.strip('/').split('/') + return ( + len(parts) == 2 and parts[0] in { + 'cancel_scan_job', 'delete_scan_job', 'restart_scan_job', + } + and bool(parts[1]) + ) + + +def _is_native_library_delete_form_path(path: str, method: str) -> bool: + parts = path.strip('/').split('/') + return method == 'POST' and len(parts) == 2 and parts[0] == 'delete_full_library' and bool(parts[1]) + + +def _is_native_game_delete_path(path: str, method: str) -> bool: + if path in {'/delete_folder', '/delete_full_game', '/delete_image'}: + return method == 'POST' + parts = path.strip('/').split('/') + return method == 'POST' and len(parts) == 2 and parts[0] == 'delete_game' and bool(parts[1]) + + +def _is_native_unmatched_compat_mutation_path(path: str, method: str) -> bool: + if method != 'POST': + return False + if path in {'/delete_all_unmatched_folders', '/update_unmatched_folder_status'}: + return True + parts = path.strip('/').split('/') + return len(parts) == 2 and parts[0] in { + 'clear_unmatched_entry', 'toggle_ignore_status', + } and bool(parts[1]) + + +def _is_native_admin_delete_image_path(path: str, method: str) -> bool: + value = path.removeprefix('/admin/api/delete_image/') + return method == 'DELETE' and bool(value) and value.isdigit() + + +def _is_native_provider_catalog_path(path: str) -> bool: + return path in { + '/api/providers', '/api/patch-catalog/providers', + '/api/patch-catalog/search', '/api/patch-catalog/attach', + } + + +def _is_native_integrations_inventory_path(path: str) -> bool: + return path == '/api/admin/integrations/inventory' + + +def _is_native_integration_settings_path(path: str) -> bool: + return path in {'/api/admin/integrations/oidc', '/api/admin/integrations/community', '/admin/integrations/oidc/save', '/admin/integrations/community'} + + +def _is_native_artwork_apply_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 and parts[:2] == ['api', 'games'] + and bool(parts[2]) and parts[3:] == ['artwork', 'steamgriddb'] + ) + + +def _is_native_admin_scan_filters_path(path: str) -> bool: + if path == '/api/admin/scan-filters': + return True + parts = path.strip('/').split('/') + return len(parts) == 4 and parts[:3] == ['api', 'admin', 'scan-filters'] and parts[3].isdigit() + + +def _is_native_admin_discovery_sections_path(path: str) -> bool: + if path in { + '/admin/api/discovery_sections', + '/admin/api/discovery_sections/order', + '/admin/api/discovery_sections/visibility', + }: + return True + parts = path.strip('/').split('/') + return ( + len(parts) == 4 + and parts[:3] == ['admin', 'api', 'discovery_sections'] + and parts[3].isdigit() + ) or ( + len(parts) == 5 + and parts[:3] == ['admin', 'api', 'discovery_sections'] + and parts[3].isdigit() + and parts[4] in {'schedule', 'pin'} + ) + + +def _is_native_admin_invites_path(path: str) -> bool: + return path in {'/admin/api/invites', '/admin/api/invites/quota', '/admin/manage_invites'} + + +def _is_native_admin_users_read_path(path: str) -> bool: + if path == '/admin/api/users': + return True + parts = path.strip('/').split('/') + return len(parts) == 4 and parts[:3] == ['admin', 'api', 'user'] and parts[3].isdigit() + + +def _is_native_admin_user_create_path(path: str) -> bool: + return path == '/admin/api/user/0' + + +def _is_native_admin_user_mutation_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 4 and parts[:3] == ['admin', 'api', 'user'] + and parts[3].isdigit() and int(parts[3]) > 0 + ) + + +def _is_native_admin_whitelist_path(path: str) -> bool: + if path in {'/admin/api/whitelist', '/admin/whitelist'}: + return True + parts = path.strip('/').split('/') + return len(parts) == 3 and parts[:2] == ['admin', 'whitelist'] and parts[2].isdigit() + + +def _is_native_admin_plugin_path(path: str) -> bool: + if path == '/api/plugins': + return True + parts = path.strip('/').split('/') + return len(parts) == 3 and parts[:2] == ['api', 'plugins'] and bool(parts[2]) + + +def _is_native_admin_newsletter_path(path: str) -> bool: + parts = path.strip('/').split('/') + return path == '/api/admin/newsletters' or ( + len(parts) == 4 and parts[:3] == ['api', 'admin', 'newsletters'] + and parts[3].isdigit() + ) + + +def _is_native_auth_confirmation_path(path: str) -> bool: + parts = path.strip('/').split('/') + return len(parts) == 4 and parts[:3] == ['api', 'auth', 'confirm'] and bool(parts[3]) + + +def _is_native_auth_page_path(path: str) -> bool: + if path in {'/login', '/register', '/reset_password_request'}: + return True + parts = path.strip('/').split('/') + return ( + len(parts) == 2 and parts[0] == 'confirm' and bool(parts[1]) + ) or ( + len(parts) == 2 and parts[0] == 'reset_password' and bool(parts[1]) + ) + + +def _is_native_setup_page_path(path: str) -> bool: + return path in { + '/setup', '/setup/smtp', '/setup/features', '/setup/igdb', + '/setup/integrations', '/setup/library', + } + + +def _is_native_password_reset_confirm_path(path: str) -> bool: + parts = path.strip('/').split('/') + return len(parts) == 4 and parts[:3] == ['api', 'auth', 'password-reset'] and bool(parts[3]) + + +def _is_native_invite_delete_path(path: str) -> bool: + return re.fullmatch(r'/delete_invite/[^/]+', path) is not None + + +def _is_native_download_request_reads_path(path: str) -> bool: + if path in {'/api/my_downloads', '/api/admin/download-requests'}: + return True + parts = path.strip('/').split('/') + return ( + (len(parts) == 2 and parts[0] == 'check_download_status') + or (len(parts) == 2 and parts[0] == 'delete_download' and parts[1].isdigit()) + or (len(parts) == 2 and parts[0] == 'delete_download_request' and parts[1].isdigit()) + or (len(parts) == 3 and parts[:2] == ['api', 'delete_download'] and parts[2].isdigit()) + ) + + +def _is_native_manual_identify_read_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + path == '/api/admin/manual-identify/options' + or (len(parts) == 4 and parts[:3] == ['api', 'admin', 'manual-identify']) + ) + + +def _is_native_manual_identify_mutation_path(path: str, method: str) -> bool: + parts = path.strip('/').split('/') + return ( + path == '/api/admin/manual-identify' and method == 'POST' + ) or ( + len(parts) == 4 + and parts[:3] == ['api', 'admin', 'manual-identify'] + and method == 'PUT' + ) + + +def _is_native_retroachievements_settings_path(path: str) -> bool: + if path in { + '/api/admin/integrations/retroachievements', + '/api/retroachievements/status', + '/api/retroachievements/match', + '/api/me/retroachievements', + }: + return True + parts = path.strip('/').split('/') + return len(parts) == 4 and parts[:2] == ['api', 'games'] and parts[3] == 'achievements' + + +def _is_native_admin_policy_settings_path(path: str) -> bool: + return path in {'/api/admin/remote-play/config', '/api/admin/scan-match/config'} + + +def _is_native_launcher_import_read_path(path: str) -> bool: + return path == '/api/imports/launchers' + + +def _is_native_download_mutation_path(path: str) -> bool: + parts = path.strip('/').split('/') + return (path.startswith('/download_game/') and len(parts) == 2 and bool(parts[1])) or ( + path.startswith('/download_other/') and len(parts) == 4 and all(parts[1:]) + ) or ( + len(parts) == 5 and parts[:2] == ['api', 'games'] + and parts[2] and parts[3:] == ['versions', 'cleanup_orphans'] + ) or ( + len(parts) == 4 and parts[:3] == ['api', 'downloads', 'games'] and parts[3] + ) + + +def _is_native_support_mutation_path(path: str) -> bool: + if path == '/api/support/tickets': + return True + parts = path.strip('/').split('/') + return ( + len(parts) == 5 and parts[:3] == ['api', 'support', 'tickets'] + and parts[3].isdigit() and parts[4] == 'resolve' + ) + + +def _is_native_launcher_import_mutation_path(path: str) -> bool: + return path in {'/api/imports/playnite', '/api/imports/launcher'} + + +def _is_native_chat_messages_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:3] == ['api', 'chat', 'channels'] + and parts[3].isdigit() + and parts[4] == 'messages' + ) + + +def _is_native_chat_attachment_upload_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:3] == ['api', 'chat', 'channels'] + and parts[3].isdigit() + and parts[4] == 'attachments' + ) + + +def _is_native_layout_preset_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:4] == ['api', 'layouts', 'detail', 'presets'] + and parts[4].isdigit() + ) + + +def _is_native_chat_mute_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:3] == ['api', 'chat', 'channels'] + and parts[3].isdigit() + and parts[4] in ('mute', 'archive', 'leave') + ) + + +def _is_native_chat_reaction_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:3] == ['api', 'chat', 'messages'] + and parts[3].isdigit() + and parts[4] == 'reactions' + ) + + +def _is_native_chat_space_read_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:3] == ['api', 'chat', 'spaces'] + and parts[3].isdigit() + and parts[4] in ('members', 'invites') + ) + + +def _is_native_chat_space_member_delete_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 6 + and parts[:3] == ['api', 'chat', 'spaces'] + and parts[3].isdigit() + and parts[4] == 'members' + and parts[5].isdigit() + ) + + +def _is_native_chat_space_channel_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:3] == ['api', 'chat', 'spaces'] + and parts[3].isdigit() + and parts[4] == 'channels' + ) + + +def _is_native_chat_space_invite_revoke_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 6 + and parts[:4] == ['api', 'chat', 'spaces', 'invites'] + and parts[4].isdigit() + and parts[5] == 'revoke' + ) + + +def _is_native_chat_emoji_delete_path(path: str) -> bool: + parts = path.strip('/').split('/') + return len(parts) == 4 and parts[:3] == ['api', 'chat', 'emoji'] and bool(parts[3]) + + +def _is_native_chat_voice_room_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 7 + and parts[:3] == ['api', 'chat', 'spaces'] + and parts[3].isdigit() + and parts[4] == 'voice' + and parts[5].isdigit() + and parts[6] == 'room' + ) + + +def _is_native_game_read_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 4 + and parts[0:2] == ['api', 'games'] + and bool(parts[2]) + and parts[3] in { + 'details', 'more_from', 'editions', 'versions', 'freshness', 'assists', + 'input_family', + } + ) or ( + len(parts) == 5 + and parts[0:2] == ['api', 'games'] + and bool(parts[2]) + and parts[3:] == ['freshness', 'check'] + ) + + +def _is_native_playtime_path(path: str) -> bool: + parts = path.strip('/').split('/') + if parts in (['api', 'playtime', 'me'], ['api', 'playtime', 'sessions']): + return True + if ( + len(parts) == 4 and parts[:3] == ['api', 'playtime', 'share'] + and parts[3].endswith('.svg') and len(parts[3]) > 4 + ): + return True + if len(parts) == 4 and parts[:3] == ['api', 'playtime', 'games'] and parts[3]: + return True + return ( + len(parts) == 5 + and parts[:3] == ['api', 'playtime', 'sessions'] + and parts[3].isdigit() + and parts[4] in {'heartbeat', 'stop'} + ) + + +def _is_native_related_media_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 4 + and parts[:2] == ['api', 'games'] + and bool(parts[2]) + and parts[3] == 'related_media' + ) or ( + len(parts) == 5 + and parts[:2] == ['api', 'games'] + and bool(parts[2]) + and parts[3] == 'related_media' + and parts[4].isdigit() + ) + + +def _is_native_game_asset_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 3 + and parts[0] == 'api' + and parts[1] in {'game_screenshots', 'game_images'} + and bool(parts[2]) + ) + + +def _is_native_member_game_state_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 3 + and parts[0] == 'api' + and parts[1] in { + 'check_favorite', 'toggle_favorite', 'get_game_status', 'set_game_status', + } + and bool(parts[2]) + ) + + +def _is_native_member_profile_path(path: str) -> bool: + parts = path.strip('/').split('/') + if len(parts) == 4: + return parts[:2] == ['api', 'users'] and parts[2].isdigit() and parts[3] == 'profile' + return ( + len(parts) == 5 + and parts[:2] == ['api', 'users'] + and parts[2].isdigit() + and parts[3] == 'compare' + and parts[4].isdigit() + ) + + +def _is_native_vr_game_path(path: str) -> bool: + parts = path.strip('/').split('/') + return len(parts) == 4 and parts[:3] == ['api', 'vr', 'games'] and bool(parts[3]) + + +def _is_native_vr_profile_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 4 + and parts[:2] == ['api', 'games'] + and bool(parts[2]) + and parts[3] in {'vr_profiles', 'vr_compat'} + ) or ( + len(parts) == 5 + and parts[:2] == ['api', 'games'] + and bool(parts[2]) + and parts[3] == 'vr_profiles' + and bool(parts[4]) + ) + + +def _is_native_saved_filter_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 4 + and parts[:3] == ['api', 'filters', 'saved'] + and parts[3].isdigit() + ) + + +def _is_native_ownership_candidate_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:3] == ['api', 'ownership', 'titles'] + and parts[3].isdigit() + and parts[4] == 'candidates' + ) + + +def _is_native_ownership_decision_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:3] == ['api', 'ownership', 'titles'] + and parts[3].isdigit() + and parts[4] == 'match' + ) or ( + len(parts) == 6 + and parts[:3] == ['api', 'ownership', 'titles'] + and parts[3].isdigit() + and parts[4:] == ['match', 'undo'] + ) + + +def _is_native_ownership_cancel_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:2] == ['api', 'ownership'] + and bool(parts[2]) + and parts[3:] == ['sync', 'cancel'] + ) + + +def _is_native_ownership_action_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 3 + and parts[:2] == ['api', 'ownership'] + and bool(parts[2]) + and parts[2] != 'sync-all' + ) or ( + len(parts) == 4 + and parts[:2] == ['api', 'ownership'] + and bool(parts[2]) + and parts[3] in {'sync', 'csv'} + ) or path == '/api/ownership/sync-all' + + +def _is_native_game_server_path(path: str) -> bool: + if path in ('/api/game-servers', '/api/game-servers/acl-check'): + return True + parts = path.strip('/').split('/') + return ( + len(parts) == 3 + and parts[:2] == ['api', 'game-servers'] + and bool(parts[2]) + ) or ( + len(parts) == 4 + and parts[:2] == ['api', 'game-servers'] + and bool(parts[2]) + and parts[3] == 'status' + ) + + +def _is_native_admin_ownership_sync_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 7 + and parts[:3] == ['api', 'admin', 'ownership'] + and parts[3] == 'connections' + and parts[4].isdigit() + and bool(parts[5]) + and parts[6] in {'sync', 'cancel'} + ) + + +def _is_native_emulator_mutation_path(path: str) -> bool: + parts = path.strip('/').split('/') + if len(parts) == 3 and parts[:2] == ['api', 'emulator-bios']: + return parts[2] in {'scan', 'install'} + if path == '/api/emulator-bios': + return True + if len(parts) < 4 or parts[:2] != ['api', 'games'] or not parts[2]: + return False + if len(parts) == 4 and parts[3] in {'cheats', 'saves', 'pc_cheats'}: + return True + return ( + len(parts) >= 5 + and parts[3] in {'cheats', 'saves', 'pc_cheats'} + and bool('/'.join(parts[4:])) + and (parts[3] != 'pc_cheats' or len(parts) == 5 and parts[4].isdigit()) + ) + + +def _is_native_game_mods_path(path: str) -> bool: + if path == '/api/mods/summary': + return True + parts = path.strip('/').split('/') + if len(parts) < 4 or parts[:2] != ['api', 'games'] or not parts[2] or parts[3] != 'mods': + return False + if len(parts) == 4: + return True + if len(parts) == 5: + return parts[4] in {'catalog', 'profiles', 'pack'} or bool(parts[4]) + if len(parts) == 6: + return parts[4] == 'profiles' and bool(parts[5]) + return len(parts) == 7 and parts[4] == 'profiles' and bool(parts[5]) and parts[6] in {'activate', 'export'} + + +def _is_native_unmatched_mutation_path(path: str) -> bool: + parts = path.strip('/').split('/') + if len(parts) != 4 or parts[:2] != ['api', 'unmatched_folders']: + return False + if parts[2] == 'batch': + return parts[3] in {'clear', 'mark_kind', 'fix', 'amend'} + return bool(parts[2]) and parts[3] in { + 'name', 'fix', 'mark_kind', 'retry_match', 'place_update', + } + + +def _is_native_unmatched_feedback_path(path: str) -> bool: + if path in { + '/api/unmatched_folders/reclassify_duplicates', + '/api/unmatched_folders/backfill_suggested_kind', + '/api/unmatched/bad_match_reasons', + }: + return True + parts = path.strip('/').split('/') + return ( + len(parts) == 4 and parts[:2] == ['api', 'unmatched'] + and bool(parts[2]) and parts[3] == 'bad_match' + ) + + +def _is_native_emulator_platform_path(path: str) -> bool: + parts = path.strip('/').split('/') + return len(parts) == 3 and parts[:2] == ['api', 'emulators'] and bool(parts[2]) + + +def _is_native_file_type_path(path: str) -> bool: + return path in ('/api/file_types/allowed', '/api/file_types/ignored') + + +def _is_native_free_games_claim_path(path: str) -> bool: + parts = path.strip('/').split('/') + return ( + len(parts) == 5 + and parts[:3] == ['api', 'news', 'free-games'] + and parts[3].isdigit() + and parts[4] == 'claim-assist' + ) + + +class LazyASGIApp: + def __init__(self): + self._app = None + self._flask_app = None + self._native_sqlalchemy_engine = None + self._runtime_config = None + self._init_lock = asyncio.Lock() + self._static_root: Path | None = ( + Path(__file__).resolve().parent / 'oneirodex' / 'static' + ) + self._native_admin_pilot = None + self._native_admin_routes = frozenset() + self._native_read_api_app = None + self._native_member_shell_app = None + self._native_read_api_routes = frozenset({ + '/', + '/index', + '/restricted', + '/api/trailers/filters', + '/api/trailers/random', + '/api/acquire/status', + '/api/acquire/search', + '/api/acquire/download', + '/api/social/status', + '/api/social/friends', + '/api/activity', + '/api/search', + '/api/get_company_role', + '/api/search_igdb_by_id', + '/api/search_igdb_by_name', + '/api/storage/hardlink/preview', + '/api/storage/hardlink/apply', + '/api/get_next_custom_igdb_id', + '/api/move_game_to_library', + '/api/favorites', + '/api/current_user_role', + '/api/icon-packs', + '/api/check_username', + '/api/vr/catalog', + '/api/filters/bundle', + '/api/library_platforms', + '/api/get_libraries', + '/api/scan_jobs_status', + '/api/path/open', + '/logout', + '/demo', + '/api/auth/login', + '/api/auth/register', + '/api/admin/newsletters', + '/api/admin/server-settings', + '/api/check_igdb_id', + '/api/get_cover_thumbnail', + '/api/reorder_libraries', + '/api/admin/libraries/batch/edit', + '/api/library/preview-cropped-image', + '/api/genres', + '/api/themes', + '/api/game_modes', + '/api/player_perspectives', + '/api/igdb_platforms', + '/api/filters/fields', + '/api/filters/saved', + '/api/filters/preview', + '/api/games/batch/favorite', + '/api/games/batch/status', + '/api/games/batch/wishlist', + '/api/games/batch/freshness/check', + '/api/admin/freshness/refresh', + '/api/games/batch/refresh_images', + '/api/playtime/me', + '/api/oidc/status', + '/api/remote-play/status', + '/api/settings/module-status', + '/api/health/library', + '/api/quality-profiles', + '/api/storage/status', + '/api/admin/malware-scan/status', + '/api/admin/malware-scan', + '/api/admin/challenge-solver/status', + '/api/admin/challenge-solver/config', + '/api/admin/challenge-solver/test', + '/api/admin/ambient-lighting/status', + '/api/admin/ambient-lighting/config', + '/api/admin/ambient-lighting/test', + '/api/rom-translate/capabilities', + '/api/attract-mode/settings', + '/api/attract-mode/user-override', + '/api/setup/state', + '/api/setup/step', + '/api/admin/statistics', + '/admin/statistics/data', + '/api/hltb/stats', + '/api/hltb/bulk-refresh', + '/api/admin/games_search', + '/api/palette/suggest', + '/api/admin/integrations/metadata-providers', + '/api/licensed-catalog', + '/api/licensed-catalog/status', + '/api/licensed-catalog/refresh', + '/api/search_metadata/sources', + '/api/search_metadata', + '/api/ownership/providers', + '/api/ownership/connections', + '/api/ownership', + '/api/ownership/titles', + '/api/emulators', + '/api/emulator-profiles', + '/api/emulator/health', + '/api/openapi.json', + '/api/openapi.yaml', + '/api/export/esde', + '/api/export/pegasus', + '/api/browser-player-settings', + '/api/check_path_availability', + '/api/library_roots', + '/api/browse_folders_ss', + '/api/unmatched_folders', + '/api/unmatched_folders/duplicates', + '/api/unmatched_folders/fix_logs', + '/api/unmatched_folders/export', + '/api/admin/themes', + '/admin/api/art-studio/stock', + '/admin/api/art-studio/system-marks', + '/admin/api/art-studio/system-marks/lab', + '/admin/api/theme/fonts', + '/admin/api/system/reset', + '/admin/api/art-studio/preview', + '/admin/api/art-studio/generate', + '/admin/api/art-studio/stock/generate', + '/admin/api/art-studio/system-marks/generate', + '/admin/api/art-studio/apply', + '/admin/api/art-studio/apply-batch', + '/admin/api/art-studio/batch-generate', + '/admin/api/artwork/generate', + '/admin/api/artwork/generate/batch', + '/admin/api/image_queue_list', + '/admin/api/download_images', + '/admin/api/images/batch_upload', + '/admin/api/covers/search', + '/admin/api/covers/batch/search', + '/admin/api/covers/apply', + '/admin/api/covers/batch/apply', + '/admin/api/artwork/auto-pick', + '/api/admin/chat-emoji', + '/api/chat/emoji', + '/api/chat/channels', + '/api/chat/spaces', + '/api/chat/search', + '/api/chat/dm', + '/api/chat/spaces/join', + '/api/layouts/detail', + '/api/layouts/detail/mine', + '/api/layouts/detail/presets', + '/api/admin/smtp/settings', + '/api/admin/smtp/test', + '/admin/smtp_settings', + '/admin/smtp_test', + '/api/admin/igdb/settings', + '/admin/igdb_settings', + '/admin/test_igdb', + '/api/admin/reference-sets', + '/api/reference-sets', + '/api/reference-sets/rehash', + '/api/reference-sets/repair-preview', + '/api/set-completion', + '/api/admin/igdb/test', + '/api/theme/fonts.css', + '/api/admin/loading-icon/config', + '/api/admin/themes/loading-icons', + '/api/loading-icon', + '/api/ai/status', + '/api/ai/config', + '/api/ai/triage', + '/api/ai/doctor-notes', + '/api/ai/apply-triage', + '/api/admin/library_platforms', + '/api/arr/status', + '/api/arr/config', + '/api/arr/indexers', + '/api/arr/module', + '/api/arr/search', + '/api/arr/download', + '/api/arr/hardlink/preview', + '/api/arr/hardlink/apply', + '/api/discover/sections', + '/api/discover/surprise', + '/api/discover/pins', + '/api/account/summary', + '/api/account/invites', + '/api/account/preferences', + '/settings_panel', + '/api/locale', + '/api/account/avatar/stock', + '/api/account/avatar', + '/settings_profile_edit', + '/api/account/password', + '/settings_password', + '/api/calendar', + '/api/tokens', + '/api/client/capabilities', + '/api/client/lifecycle', + '/api/client/heartbeat', + '/api/client/commands', + '/api/announcements', + '/api/collections', + '/api/requests', + '/api/notifications', + '/api/notifications/read', + '/api/notifications/preferences', + '/api/admin/notify-bus/test', + '/api/events/publish_test', + '/api/updates/wanted', + '/api/updates/wanted/fulfill', + '/api/updates/inbox', + '/api/updates/store_search', + '/api/updates/scan', + '/api/news/gaming', + '/api/news/free-games', + '/api/rtc/status', + '/api/rtc/token', + '/browse_games', + }) + + async def _ensure_flask(self): + """Initialize native configuration and routers once. + + Flask app injection remains available to compatibility tests which + replace ``create_app`` explicitly. Normal ASGI startup builds only the + framework-independent runtime context. + """ + if self._app is not None: + return + async with self._init_lock: + if self._app is not None: + return + from oneirodex.runtime_native.app_config import build_runtime_config + from oneirodex.runtime_native.db_session import create_native_engine + from oneirodex.runtime_native.runtime_context import NativeRuntimeContext + + compatibility_app_injected = create_app is not _DEFAULT_CREATE_APP + if compatibility_app_injected: + # Existing route-parity tests can inject an in-memory Flask + # host without making it part of the normal ASGI runtime. + self._flask_app = create_app() + self._runtime_config = dict(self._flask_app.config) + else: + self._runtime_config = build_runtime_config() + package_root = Path(__file__).resolve().parent / 'oneirodex' + self._flask_app = NativeRuntimeContext( + self._runtime_config, package_root=package_root, + ) + database_url = self._runtime_config.get('SQLALCHEMY_DATABASE_URI') + if self._native_sqlalchemy_engine is None and database_url: + self._native_sqlalchemy_engine = create_native_engine(self._runtime_config) + if isinstance(self._flask_app, NativeRuntimeContext): + self._flask_app.native_engine = self._native_sqlalchemy_engine + self._static_root = Path(self._flask_app.static_folder or '').resolve() + # Native API handlers share a FastAPI app. Compatibility route + # adapters receive config/logger/url helpers, not a Flask runtime. + from fastapi import FastAPI + from oneirodex.runtime_native.admin_pilot import _native_auth_backend + from oneirodex.runtime_native.trailers import ( + TrailerAuthCallbacks, + mount_trailers_routes, + ) + from oneirodex.runtime_native.browse import mount_browse_routes + from oneirodex.runtime_native.browse_locations import mount_browse_locations_routes + from oneirodex.runtime_native.acquire_status import mount_acquire_status_routes + from oneirodex.runtime_native.acquire_actions import mount_acquire_actions_routes + from oneirodex.runtime_native.social_reads import mount_social_reads_routes + from oneirodex.runtime_native.game_reads import mount_game_reads_routes + from oneirodex.runtime_native.game_delete_mutations import mount_game_delete_mutation_routes + from oneirodex.runtime_native.filter_reads import mount_filter_reads_routes + from oneirodex.runtime_native.playtime import mount_playtime_routes + from oneirodex.runtime_native.related_media import mount_related_media_routes + from oneirodex.runtime_native.saved_filters import mount_saved_filter_routes + from oneirodex.runtime_native.batch_favorite import mount_batch_favorite_routes + from oneirodex.runtime_native.batch_status import mount_batch_status_routes + from oneirodex.runtime_native.batch_wishlist import mount_batch_wishlist_routes + from oneirodex.runtime_native.batch_refresh import mount_batch_refresh_routes + from oneirodex.runtime_native.member_game_state import mount_member_game_state_routes + from oneirodex.runtime_native.db_session import native_engine_session_scope + from oneirodex.runtime_native.favorites import mount_favorites_routes + from oneirodex.runtime_native.member_meta import mount_member_meta_routes + from oneirodex.runtime_native.vr_reads import mount_vr_read_routes + from oneirodex.runtime_native.social_writes import mount_social_writes_routes + from oneirodex.runtime_native.oidc_status import mount_oidc_status_routes + from oneirodex.runtime_native.remote_play_status import mount_remote_play_status_routes + from oneirodex.runtime_native.settings_module_status import mount_settings_module_status_routes + from oneirodex.runtime_native.library_health import mount_library_health_routes + from oneirodex.runtime_native.game_health import mount_game_health_routes + from oneirodex.runtime_native.quality_profiles import mount_quality_profiles_routes + from oneirodex.runtime_native.status_reads import mount_status_reads_routes + from oneirodex.runtime_native.device_status import mount_device_status_routes + from oneirodex.runtime_native.rom_translate import mount_rom_translate_routes + from oneirodex.runtime_native.loading_icon_admin import mount_loading_icon_admin_routes + from oneirodex.runtime_native.attract_mode_reads import mount_attract_mode_routes + from oneirodex.runtime_native.theme_font_css import mount_theme_font_routes + from oneirodex.runtime_native.ai_status import mount_ai_status_routes + from oneirodex.runtime_native.library_platforms import mount_library_platforms_routes + from oneirodex.runtime_native.library_reads import mount_library_reads_routes + from oneirodex.runtime_native.scan_job_reads import mount_scan_job_reads_routes + from oneirodex.runtime_native.scan_job_mutations import mount_scan_job_mutation_routes + from oneirodex.runtime_native.arr_status import mount_arr_status_routes + from oneirodex.runtime_native.discover_reads import mount_discover_reads_routes + from oneirodex.runtime_native.account_summary import mount_account_summary_routes + from oneirodex.runtime_native.account_preferences import mount_account_preferences_routes + from oneirodex.runtime_native.locale import mount_locale_routes + from oneirodex.runtime_native.account_stock_avatar import mount_account_stock_avatar_routes + from oneirodex.runtime_native.account_avatar_upload import mount_account_avatar_upload_routes + from oneirodex.runtime_native.account_password import mount_account_password_routes + from oneirodex.runtime_native.release_calendar import mount_release_calendar_routes + from oneirodex.runtime_native.account_tokens import mount_account_tokens_routes + from oneirodex.runtime_native.client_reads import mount_client_reads_routes + from oneirodex.runtime_native.announcements import mount_announcements_routes + from oneirodex.runtime_native.news_feeds import mount_news_feed_routes + from oneirodex.runtime_native.collection_reads import mount_collection_reads_routes + from oneirodex.runtime_native.wishlist import mount_wishlist_routes + from oneirodex.runtime_native.notifications import mount_notifications_routes + from oneirodex.runtime_native.wanted_updates import mount_wanted_updates_routes + from oneirodex.runtime_native.updates_reads import mount_update_reads_routes + from oneirodex.runtime_native.updates_scan import mount_update_scan_routes + from oneirodex.runtime_native.setup_state import mount_setup_state_routes + from oneirodex.runtime_native.admin_statistics import mount_admin_statistics_routes + from oneirodex.runtime_native.admin_search import mount_admin_search_routes + from oneirodex.runtime_native.palette_suggest import mount_palette_suggest_routes + from oneirodex.runtime_native.metadata_providers_read import mount_metadata_providers_read_routes + from oneirodex.runtime_native.licensed_catalog_reads import mount_licensed_catalog_reads_routes + from oneirodex.runtime_native.metadata_search_sources import mount_metadata_search_sources_routes + from oneirodex.runtime_native.metadata_search import mount_metadata_search_routes + from oneirodex.runtime_native.admin_themes_read import mount_admin_themes_read_routes + from oneirodex.runtime_native.ownership_providers import mount_ownership_provider_routes + from oneirodex.runtime_native.ownership_actions import mount_ownership_actions_routes + from oneirodex.runtime_native.ownership_admin_reads import mount_ownership_admin_read_routes + from oneirodex.runtime_native.game_servers import mount_game_servers_routes + from oneirodex.runtime_native.emulator_mutations import mount_emulator_mutation_routes + from oneirodex.runtime_native.game_mods import mount_game_mods_routes + from oneirodex.runtime_native.scan_unmatched_reads import mount_scan_unmatched_read_routes + from oneirodex.runtime_native.scan_unmatched_mutations import mount_scan_unmatched_mutation_routes + from oneirodex.runtime_native.scan_unmatched_feedback import mount_scan_unmatched_feedback_routes + from oneirodex.runtime_native.emulator_reads import mount_emulator_read_routes + from oneirodex.runtime_native.system_path_check import mount_system_path_check_routes + from oneirodex.runtime_native.system_file_types import mount_system_file_type_routes + from oneirodex.runtime_native.artwork_provider_search import mount_artwork_provider_search_routes + from oneirodex.runtime_native.artwork_apply_mutation import mount_artwork_apply_routes + from oneirodex.runtime_native.patch_catalog_routes import mount_patch_catalog_routes + from oneirodex.runtime_native.download_request_reads import mount_download_request_read_routes + from oneirodex.runtime_native.manual_identify_reads import mount_manual_identify_read_routes + from oneirodex.runtime_native.manual_identify_mutations import mount_manual_identify_mutation_routes + from oneirodex.runtime_native.legacy_manual_identify_forms import mount_legacy_manual_identify_routes + from oneirodex.runtime_native.retroachievements_settings import mount_retroachievements_settings_routes + from oneirodex.runtime_native.admin_policy_settings import mount_admin_policy_settings_routes + from oneirodex.runtime_native.admin_attract_mode import mount_admin_attract_mode_routes + from oneirodex.runtime_native.admin_features import mount_admin_features_routes + from oneirodex.runtime_native.admin_scan_filters import mount_admin_scan_filters_routes + from oneirodex.runtime_native.legacy_scan_forms import mount_legacy_scan_form_routes + from oneirodex.runtime_native.legacy_scan_management import mount_legacy_scan_management_routes + from oneirodex.runtime_native.admin_discovery_sections import mount_admin_discovery_sections_routes + from oneirodex.runtime_native.admin_invites import mount_admin_invites_routes + from oneirodex.runtime_native.admin_users_read import mount_admin_users_read_routes + from oneirodex.runtime_native.admin_user_create import mount_admin_user_create_routes + from oneirodex.runtime_native.admin_user_mutations import mount_admin_user_mutation_routes + from oneirodex.runtime_native.admin_art_studio_reads import mount_admin_art_studio_read_routes + from oneirodex.runtime_native.emulator_health import mount_emulator_health_routes + from oneirodex.runtime_native.openapi_documents import mount_openapi_documents_routes + from oneirodex.runtime_native.library_exports import mount_library_export_routes + from oneirodex.runtime_native.admin_theme_fonts import mount_admin_theme_font_routes + from oneirodex.runtime_native.admin_system_reset import mount_admin_system_reset_routes + from oneirodex.runtime_native.admin_art_studio_preview import mount_admin_art_studio_preview_routes + from oneirodex.runtime_native.admin_art_studio_actions import mount_admin_art_studio_action_routes + from oneirodex.runtime_native.admin_artwork_generate import mount_admin_artwork_generation_routes + from oneirodex.runtime_native.admin_artwork_batch import mount_admin_artwork_batch_routes + from oneirodex.runtime_native.admin_image_queue_read import mount_admin_image_queue_read_routes + from oneirodex.runtime_native.admin_cover_search import mount_admin_cover_search_routes + from oneirodex.runtime_native.admin_cover_apply import mount_admin_cover_apply_routes + from oneirodex.runtime_native.admin_cover_batch import mount_admin_cover_batch_routes + from oneirodex.runtime_native.admin_image_mutations import mount_admin_image_mutation_routes + from oneirodex.runtime_native.admin_whitelist import mount_admin_whitelist_routes + from oneirodex.runtime_native.admin_plugins_read import mount_admin_plugins_routes + from oneirodex.runtime_native.launcher_import_reads import mount_launcher_import_read_routes + from oneirodex.runtime_native.download_mutations import mount_download_mutation_routes + from oneirodex.runtime_native.support_mutations import mount_support_mutation_routes + from oneirodex.runtime_native.launcher_import_mutations import mount_launcher_import_mutation_routes + from oneirodex.runtime_native.library_tools import mount_library_tools_routes + from oneirodex.runtime_native.library_tools_mutations import mount_library_tools_mutations_routes + from oneirodex.runtime_native.library_tools_manual_identify import mount_manual_identify_list_routes + from oneirodex.runtime_native.library_admin_mutations import mount_library_admin_mutations_routes + from oneirodex.runtime_native.support_reads import mount_support_reads_routes + from oneirodex.runtime_native.theme_fonts_read import mount_theme_fonts_read_routes + from oneirodex.runtime_native.provider_catalog_reads import mount_provider_catalog_reads_routes + from oneirodex.runtime_native.integrations_inventory import mount_integrations_inventory_routes + from oneirodex.runtime_native.integration_settings import mount_integration_settings_routes + from oneirodex.runtime_native.admin_newsletters import mount_admin_newsletters_routes + from oneirodex.runtime_native.igdb_availability import mount_igdb_availability_routes + from oneirodex.runtime_native.igdb_company_role import mount_igdb_company_role_routes + from oneirodex.runtime_native.storage_hardlinks import mount_storage_hardlinks_routes + from oneirodex.runtime_native.malware_scan_mutation import mount_malware_scan_mutation_routes + from oneirodex.runtime_native.auth_confirmation import mount_auth_confirmation_routes + from oneirodex.runtime_native.auth_login import mount_auth_login_routes + from oneirodex.runtime_native.auth_logout import mount_auth_logout_routes + from oneirodex.runtime_native.auth_pages import mount_auth_pages_routes + from oneirodex.runtime_native.auth_registration import mount_auth_registration_routes + from oneirodex.runtime_native.password_reset_confirm import mount_password_reset_confirm_routes + from oneirodex.runtime_native.auth_form_posts import mount_auth_form_post_routes + from oneirodex.runtime_native.oidc_auth import mount_oidc_auth_routes + from oneirodex.runtime_native.setup_pages import mount_setup_pages_routes + from oneirodex.runtime_native.setup_transition import mount_setup_transition_routes + from oneirodex.runtime_native.site_redirects import mount_site_redirect_routes + from oneirodex.runtime_native.play_redirect import mount_play_redirect_routes + from oneirodex.runtime_native.styleguide import mount_styleguide_routes + from oneirodex.runtime_native.admin_server_settings import mount_admin_server_settings_routes + from oneirodex.runtime_native.admin_ops_reads import mount_admin_ops_reads_routes + from oneirodex.runtime_native.admin_redirects import mount_admin_redirect_routes + + find_user, find_token, commit_token_touch = _native_auth_backend(self._flask_app) + manager = getattr(self._flask_app, 'login_manager', None) + # Native requests resolve the signed identity through the shared + # fingerprint/active-user resolver and `find_user`; consulting + # Flask-Login's callback here would keep native authentication + # coupled to the legacy request extension. + load_user_id = None + self._native_read_api_app = FastAPI( + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + self._native_read_api_app.router.redirect_slashes = False + mount_site_redirect_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_play_redirect_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_styleguide_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.member_shell import create_member_shell_router + + self._native_member_shell_app = FastAPI( + docs_url=None, redoc_url=None, openapi_url=None, + ) + self._native_member_shell_app.router.redirect_slashes = False + self._native_member_shell_app.include_router(create_member_shell_router( + self._flask_app, + database_scope=lambda app: self._native_database_scope(app), + )) + _install_native_api_error_handlers( + self._native_read_api_app, + self._flask_app.config, + self._flask_app.logger, + ) + mount_trailers_routes( + self._native_read_api_app, + self._flask_app, + auth=TrailerAuthCallbacks( + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + ), + database_scope=lambda app: self._native_database_scope(app), + ) + mount_browse_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_browse_locations_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_acquire_status_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_acquire_actions_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_social_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_member_game_state_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_favorites_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_member_meta_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_vr_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + # Register the static /games/batch/... routes before the dynamic + # /games/{game_uuid}/... detail routes. + mount_batch_refresh_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_game_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_game_delete_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_filter_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_playtime_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_related_media_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_saved_filter_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_batch_favorite_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_batch_status_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_batch_wishlist_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_social_writes_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_oidc_status_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_remote_play_status_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_settings_module_status_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_library_health_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_game_health_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_quality_profiles_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_status_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_device_status_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_rom_translate_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_loading_icon_admin_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_attract_mode_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_theme_font_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_ai_status_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_library_platforms_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_library_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_scan_job_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_scan_job_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_newsletters_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_igdb_availability_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_igdb_company_role_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_storage_hardlinks_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_malware_scan_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_auth_confirmation_routes( + self._native_read_api_app, + app_config=self._flask_app.config, + database_scope=lambda: self._native_database_scope(self._flask_app), + ) + mount_auth_login_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_auth_logout_routes(self._native_read_api_app, self._flask_app) + mount_auth_pages_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_oidc_auth_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_setup_pages_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_setup_transition_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_auth_registration_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_password_reset_confirm_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_auth_form_post_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.auth_demo import mount_public_demo_routes + mount_public_demo_routes( + self._native_read_api_app, + self._flask_app, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_server_settings_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_ops_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_redirect_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_arr_status_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.arr_config_read import mount_arr_config_routes + mount_arr_config_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.arr_indexers_read import mount_arr_indexers_routes + mount_arr_indexers_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_discover_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_account_summary_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_account_preferences_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_locale_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_account_stock_avatar_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_account_avatar_upload_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_account_password_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_release_calendar_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_account_tokens_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_client_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_announcements_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_news_feed_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_collection_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_wishlist_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_notifications_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_wanted_updates_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_update_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_update_scan_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_setup_state_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_statistics_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.hltb_stats import mount_hltb_stats_routes + mount_hltb_stats_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.hltb_refresh import mount_hltb_refresh_routes + mount_hltb_refresh_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_search_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_palette_suggest_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_metadata_providers_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_licensed_catalog_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_metadata_search_sources_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_metadata_search_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_themes_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_ownership_provider_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_ownership_actions_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_ownership_admin_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_artwork_provider_search_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_artwork_apply_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_patch_catalog_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_download_request_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_manual_identify_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_manual_identify_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_legacy_manual_identify_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_retroachievements_settings_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_policy_settings_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_attract_mode_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_features_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_scan_filters_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_legacy_scan_form_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_legacy_scan_management_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_discovery_sections_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_invites_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_users_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_user_create_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_user_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_art_studio_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_openapi_documents_routes( + self._native_read_api_app, + self._flask_app, + ) + mount_library_export_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_theme_font_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_system_reset_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_art_studio_preview_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_art_studio_action_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_artwork_generation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_artwork_batch_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_image_queue_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_cover_search_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_cover_apply_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_cover_batch_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_image_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_emulator_health_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_whitelist_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + load_user_id=load_user_id, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_admin_plugins_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_launcher_import_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_download_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_support_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_launcher_import_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_game_servers_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_emulator_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_game_mods_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_scan_unmatched_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_scan_unmatched_mutation_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_scan_unmatched_feedback_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_library_tools_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_library_tools_mutations_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_manual_identify_list_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_library_admin_mutations_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_support_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_theme_fonts_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_provider_catalog_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_integrations_inventory_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_integration_settings_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_emulator_read_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_system_path_check_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_system_file_type_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.chat_emoji_read import mount_chat_emoji_routes + from oneirodex.runtime_native.chat_reads import mount_chat_reads_routes + from oneirodex.runtime_native.layouts import mount_layout_routes + mount_chat_emoji_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_chat_reads_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + mount_layout_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.smtp_settings_read import mount_smtp_settings_routes + mount_smtp_settings_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.igdb_settings_read import mount_igdb_settings_routes + mount_igdb_settings_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + from oneirodex.runtime_native.reference_sets_read import mount_reference_sets_routes + mount_reference_sets_routes( + self._native_read_api_app, + self._flask_app, + find_user=find_user, + find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=manager, + database_scope=lambda app: self._native_database_scope(app), + ) + admin_shell_path = self._static_root / 'dist' / 'admin-pilot' / 'index.html' + if not admin_shell_path.is_file() and not compatibility_app_injected: + raise RuntimeError('Required built admin React shell is missing from the application static tree.') + from oneirodex.runtime_native import ( + ADMIN_STATIC_ROUTES, + ADMIN_BOOTSTRAP_ROUTE, + ) + from oneirodex.runtime_native.admin_pilot import create_admin_pilot, legacy_chrome_fields + self._native_admin_routes = frozenset((*ADMIN_STATIC_ROUTES, ADMIN_BOOTSTRAP_ROUTE)) + self._native_admin_pilot = ( + create_admin_pilot( + self._flask_app, + shell_path=admin_shell_path, + chrome_fields=legacy_chrome_fields, + database_scope=lambda app: self._native_database_scope(app), + ) + if admin_shell_path.is_file() else None + ) + # Keep the initialized sentinel tied to the native router. Every + # registered legacy method/path is covered by FastAPI or an + # explicit ASGI handler; unknown paths now get native 404/405s. + self._app = self._native_read_api_app + + async def _ensure_native_database(self): + """Prepare framework-free runtime config and DB engine for probes.""" + if self._runtime_config is not None and self._native_sqlalchemy_engine is not None: + return + async with self._init_lock: + if self._runtime_config is None: + from oneirodex.runtime_native.app_config import build_runtime_config + + self._runtime_config = build_runtime_config() + if self._native_sqlalchemy_engine is None: + from oneirodex.runtime_native.db_session import create_native_engine + + if self._runtime_config.get('SQLALCHEMY_DATABASE_URI'): + self._native_sqlalchemy_engine = create_native_engine(self._runtime_config) + + def _native_database_scope(self, app=None): + """Return a native Session scope; retain a test/legacy fallback only.""" + from oneirodex.runtime_native.db_session import ( + native_engine_session_scope, + native_session_scope, + ) + + if self._native_sqlalchemy_engine is not None: + from oneirodex.runtime_native.runtime_context import NativeRuntimeContext + + if isinstance(self._flask_app, NativeRuntimeContext): + return self._flask_app.app_context() + return native_engine_session_scope(self._native_sqlalchemy_engine, db) + return native_session_scope(app or self._flask_app, db) + + async def __call__(self, scope, receive, send): + if scope["type"] == "lifespan": + await self._handle_lifespan(receive, send) + elif scope["type"] == "http": + path = scope["path"] + + # Liveness has no DB, session, or app-context dependency. Keep it + # available to the container probe without constructing Flask on + # the first HTTP request. Normal lifespan startup may still eager + # initialize Flask for the remaining compatibility routes. + if path == '/pulse' and scope.get('method') in ('GET', 'HEAD'): + await self._handle_liveness(scope, send) + return + + if path == '/awake' and scope.get('method') in ('GET', 'HEAD'): + await self._ensure_native_database() + await self._handle_readiness(scope, send) + return + + if path == '/manifest.webmanifest' and scope.get('method') in ('GET', 'HEAD'): + await self._handle_app_manifest(scope, send) + return + + if path == '/offline' and scope.get('method') in ('GET', 'HEAD'): + await self._handle_offline(scope, send) + return + + if path in ('/sw.js', '/vr/sw.js') and scope.get('method') in ('GET', 'HEAD'): + await self._handle_service_worker(scope, send, path) + return + + if path == '/favicon.ico' and scope.get('method') in ('GET', 'HEAD'): + await self._handle_favicon(scope, send) + return + + if path == '/api/emulator/installed-cores.js' and scope.get('method') in ('GET', 'HEAD'): + await self._handle_installed_cores(scope, send) + return + + if ( + is_email_confirmation_page_route(path) + and scope.get('method') in ('GET', 'HEAD') + ): + headers = dict(scope.get('headers', [])) + # This document route has no token side effect. Preserve queued + # Flask flashes and request-loader auth whenever credentials + # are present; a first-time credential-free visit can use the + # shipped React shell directly. + if not headers.get(b'cookie') and not headers.get(b'authorization'): + await self._handle_confirmation_shell(scope, send) + return + + if ( + path in {'/login', '/reset_password_request'} + and scope.get('method') in ('GET', 'HEAD') + ): + headers = dict(scope.get('headers', [])) + # Existing sessions must retain the Flask-Login redirect and + # request-loader credentials must retain their auth behavior. + if not headers.get(b'cookie') and not headers.get(b'authorization'): + await self._handle_confirmation_shell(scope, send) + return + + if path.startswith('/download_zip/') or path.startswith('/api/downloadrom/'): + await self._handle_download(scope, receive, send) + return + + # Long-lived SSE must not run through request router middleware — a single + # sync stream holds a bridge thread for the life of the connection + # and freezes Discover/Admin/API fetches. True of any bridge. + sse_key = path.rstrip('/') or '/' + if sse_key in self._SSE_ROUTES: + cfg = self._SSE_ROUTES[sse_key] + await self._handle_sse( + scope, + receive, + send, + channel=cfg['channel'], + event_types=cfg['event_types'], + restrict_child=cfg['restrict_child'], + ) + return + + # Serve /static/* natively. This began as a workaround for the + # bridge failing under concurrent CSS/JS — it stays because the native path streams with + # aiofiles and owns its own cache-control and security headers. + if path.startswith('/static/'): + await self._handle_static(scope, receive, send, path) + return + + await self._ensure_flask() + from oneirodex.runtime_native.member_shell import member_shell_route + + if ( + self._native_member_shell_app is not None + and member_shell_route(path) + and scope.get('method') in ('GET', 'HEAD') + ): + await self._native_member_shell_app(scope, receive, send) + return + if ( + self._native_read_api_app is not None + and path in {'/admin/integrations/igdb/save', '/admin/integrations/igdb/test'} + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and path in {'/admin/settings', '/admin/new_server_settings'} + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and path == '/admin/attract_mode_settings' + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and path == '/admin/edit_filters' + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and path == '/admin/reference_sets' + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and path == '/admin/chat_emoji' + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and is_local_game_image_route(path) + and scope.get('method') in ('GET', 'HEAD') + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and is_game_image_refresh_route(path) + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and is_single_image_upload_route(path) + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and scope.get('method') == 'POST' + and (path == '/admin/library/add' or is_library_edit_route(path)) + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and path == '/link_existing_game' + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and path == '/scan_manual_folder' + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and path == '/scan_management' + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and (path == '/add_game_manual' or is_game_edit_route(path)) + and scope.get('method') == 'POST' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and path in {'/login/oidc', '/login/oidc/callback'} + and scope.get('method') == 'GET' + ): + await self._native_read_api_app(scope, receive, send) + return + + if ( + self._native_read_api_app is not None + and ( + path in self._native_read_api_routes + or (path.startswith('/play_game/') and scope.get('method') in ('GET', 'HEAD')) + or (path.startswith('/delete_filter/') and scope.get('method') in ('GET', 'HEAD', 'POST')) + or (path == '/dev/styleguide' and scope.get('method') in ('GET', 'HEAD')) + or ( + path in {'/settings_profile_view', '/settings_password', '/settings_panel'} + and scope.get('method') in ('GET', 'HEAD') + ) + or _is_native_library_api_path(path) + or _is_native_admin_newsletter_path(path) + or _is_native_auth_confirmation_path(path) + or ( + _is_native_auth_page_path(path) + ) + or ( + _is_native_setup_page_path(path) + ) + or path == '/user/invites' + or _is_native_invite_delete_path(path) + or ( + path in { + '/setup/submit', '/setup/smtp', '/setup/features', + '/setup/igdb', '/setup/integrations', '/setup/library', + } + and ( + scope.get('method') == 'POST' + or ( + scope.get('method') in ('GET', 'HEAD') + ) + ) + ) + or (is_admin_redirect_path(path) + and scope.get('method') in ('GET', 'HEAD')) + or _is_native_password_reset_confirm_path(path) + or _is_native_chat_messages_path(path) + or _is_native_chat_attachment_upload_path(path) + or _is_native_chat_mute_path(path) + or _is_native_chat_reaction_path(path) + or _is_native_chat_space_read_path(path) + or _is_native_chat_space_member_delete_path(path) + or _is_native_chat_space_channel_path(path) + or _is_native_chat_space_invite_revoke_path(path) + or _is_native_chat_emoji_delete_path(path) + or _is_native_chat_voice_room_path(path) + or _is_native_layout_preset_path(path) + or _is_native_game_read_path(path) + or _is_native_playtime_path(path) + or _is_native_related_media_path(path) + or _is_native_game_asset_path(path) + or _is_native_member_game_state_path(path) + or _is_native_member_profile_path(path) + or _is_native_vr_game_path(path) + or _is_native_vr_profile_path(path) + or _is_native_saved_filter_path(path) + or _is_native_ownership_candidate_path(path) + or _is_native_ownership_decision_path(path) + or _is_native_ownership_cancel_path(path) + or _is_native_ownership_action_path(path) + or _is_native_game_server_path(path) + or _is_native_emulator_mutation_path(path) + or _is_native_game_mods_path(path) + or _is_native_unmatched_mutation_path(path) + or _is_native_unmatched_feedback_path(path) + or _is_native_emulator_platform_path(path) + or _is_native_file_type_path(path) + or _is_native_library_tools_path(path) + or _is_native_library_admin_mutation_path(path) + or _is_native_support_reads_path(path) + or _is_native_theme_fonts_read_path(path) + or _is_native_provider_catalog_path(path) + or _is_native_integrations_inventory_path(path) + or _is_native_integration_settings_path(path) + or ( + path in {'/admin/integrations/igdb/save', '/admin/integrations/igdb/test'} + and scope.get('method') == 'POST' + ) + or _is_native_download_request_reads_path(path) + or _is_native_manual_identify_read_path(path) + or _is_native_manual_identify_mutation_path(path, scope.get('method', '')) + or _is_native_retroachievements_settings_path(path) + or _is_native_admin_policy_settings_path(path) + or path == '/api/admin/attract-mode/settings' + or _is_native_admin_scan_filters_path(path) + or _is_native_admin_discovery_sections_path(path) + or ( + _is_native_admin_invites_path(path) + and (path != '/admin/manage_invites' or scope.get('method') == 'POST') + ) + or ( + _is_native_admin_whitelist_path(path) + and ( + (path == '/admin/api/whitelist' and scope.get('method') in ('GET', 'HEAD', 'POST')) + or (path == '/admin/whitelist' and scope.get('method') == 'POST') + or (path != '/admin/api/whitelist' and scope.get('method') == 'DELETE') + ) + ) + or ( + _is_native_admin_users_read_path(path) + and scope.get('method') in ('GET', 'HEAD') + ) + or ( + _is_native_admin_user_create_path(path) + and scope.get('method') == 'PUT' + ) + or ( + _is_native_admin_user_mutation_path(path) + and scope.get('method') in ('PUT', 'DELETE') + ) + or _is_native_admin_plugin_path(path) + or ( + path == '/admin/api/system_logs/clear' + and scope.get('method') == 'DELETE' + ) + or ( + path == '/admin/clear_permission_errors' + and scope.get('method') == 'POST' + ) + or _is_native_admin_theme_font_path(path, scope.get('method', '')) + or _is_native_admin_art_studio_download_path(path, scope.get('method', '')) + or _is_native_image_refresh_progress_path(path, scope.get('method', '')) + or _is_native_library_delete_progress_path(path, scope.get('method', '')) + or _is_native_library_delete_sse_path(path, scope.get('method', '')) + or _is_native_scan_job_mutation_path(path, scope.get('method', '')) + or _is_native_library_delete_form_path(path, scope.get('method', '')) + or _is_native_game_delete_path(path, scope.get('method', '')) + or _is_native_unmatched_compat_mutation_path(path, scope.get('method', '')) + or _is_native_admin_delete_image_path(path, scope.get('method', '')) + or path in { + '/api/admin/features/settings', + '/api/admin/features/challenge', + '/api/admin/features/challenge/test', + } + or (path == '/admin/features' and scope.get('method') == 'POST') + or (path in {'/admin/settings', '/admin/new_server_settings'} and scope.get('method') == 'POST') + or (path in {'/admin/igdb_settings', '/admin/test_igdb', '/admin/integrations/igdb/save', '/admin/integrations/igdb/test'} and scope.get('method') == 'POST') + or _is_native_launcher_import_read_path(path) + or _is_native_download_mutation_path(path) + or _is_native_support_mutation_path(path) + or _is_native_launcher_import_mutation_path(path) + or _is_native_artwork_apply_path(path) + or path == '/api/admin/ownership/connections' + or _is_native_admin_ownership_sync_path(path) + or path in { + '/api/providers/steamgriddb/search', + '/api/providers/igdb/search', + '/api/providers/giantbomb/search', + '/api/providers/pcgamingwiki/search', + '/api/providers/meta_quest/search', + '/admin/api/ops/logs', + '/admin/api/ops/system', + '/admin/api/ops/summary', + '/admin/api/ops/devices', + '/admin/api/library/health', + } + or path.startswith('/api/health/games/') + or path.startswith('/api/discover/rows/') + or path.startswith('/api/discover/zones/') + or path.startswith('/api/discover/hubs/genre/') + or path.startswith('/api/account/invites/') + or path.startswith('/api/tokens/') + or path.startswith('/api/client/commands/') + or path.startswith('/api/collections/') + or path.startswith('/api/social/friends/') + or path.startswith('/api/requests/') + or path.startswith('/api/admin/chat-emoji/') + or path.startswith('/api/chat/attachments/') + or path.startswith('/api/admin/themes/') + or path == '/admin/themes/reset' + or ( + path == '/admin/themes' + and scope.get('method') == 'POST' + ) + or path.startswith('/admin/themes/delete/') + or path.startswith('/api/admin/reference-sets/') + or ( + path.startswith('/api/reference-sets/') + and path.rsplit('/', 1)[-1].isdigit() + ) + or path.startswith('/api/arr/indexers/') + or path.startswith('/api/quality-profiles/') + or path.startswith('/api/hltb/refresh/') + or _is_native_free_games_claim_path(path) + ) + and ( + scope.get('method') in ('GET', 'HEAD') + or ( + _is_native_library_api_path(path) + and path.endswith('/watch') + and scope.get('method') == 'PUT' + ) + or ( + _is_native_library_api_path(path) + and path.startswith('/api/admin/libraries/') + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/reorder_libraries' + and scope.get('method') == 'POST' + ) + or ( + path in ( + '/api/storage/hardlink/preview', + '/api/storage/hardlink/apply', + ) + and scope.get('method') == 'POST' + ) + or ( + path == '/api/admin/malware-scan' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/library/preview-cropped-image' + and scope.get('method') == 'POST' + ) + or (path == '/api/rtc/token' and scope.get('method') == 'POST') + or ( + _is_native_free_games_claim_path(path) + and scope.get('method') == 'POST' + ) + or ( + _is_native_password_reset_confirm_path(path) + and scope.get('method') == 'POST' + ) + or (path == '/api/auth/login' and scope.get('method') == 'POST') + or (path == '/api/auth/register' and scope.get('method') == 'POST') + or (path == '/user/invites' and scope.get('method') == 'POST') + or (_is_native_invite_delete_path(path) and scope.get('method') == 'POST') + or path in {'/login', '/register', '/reset_password_request'} + and scope.get('method') == 'POST' + or ( + _is_native_auth_page_path(path) + and path.startswith('/reset_password/') + and scope.get('method') == 'POST' + ) + or (path == '/api/setup/step' and scope.get('method') == 'POST') + or ( + path in { + '/setup/submit', '/setup/smtp', '/setup/features', + '/setup/igdb', '/setup/integrations', '/setup/library', + } + and scope.get('method') == 'POST' + ) + or ( + path == '/api/admin/libraries/batch/edit' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/account/invites' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/account/preferences' + and scope.get('method') == 'PUT' + ) + or (path == '/api/locale' and scope.get('method') == 'POST') + or ( + path == '/api/admin/loading-icon/config' + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/attract-mode/user-override' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/admin/integrations/metadata-providers' + and scope.get('method') == 'PUT' + ) + or ( + _is_native_integration_settings_path(path) + and scope.get('method') in ('PUT', 'POST') + ) + or ( + _is_native_download_request_reads_path(path) + and path.startswith('/api/delete_download/') + and scope.get('method') == 'DELETE' + ) + or ( + _is_native_retroachievements_settings_path(path) + and ( + (path in { + '/api/admin/integrations/retroachievements', + '/api/me/retroachievements', + } and scope.get('method') == 'PUT') + or (path == '/api/retroachievements/match' and scope.get('method') == 'POST') + ) + ) + or ( + _is_native_admin_policy_settings_path(path) + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/admin/attract-mode/settings' + and scope.get('method') == 'PUT' + ) + or ( + _is_native_admin_scan_filters_path(path) + and scope.get('method') in ('POST', 'DELETE') + ) + or _is_native_admin_discovery_sections_path(path) + or ( + _is_native_admin_invites_path(path) + and (path != '/admin/manage_invites' or scope.get('method') == 'POST') + ) + or ( + _is_native_admin_whitelist_path(path) + and ( + (path == '/admin/api/whitelist' and scope.get('method') in ('GET', 'HEAD', 'POST')) + or (path == '/admin/whitelist' and scope.get('method') == 'POST') + or (path != '/admin/api/whitelist' and scope.get('method') == 'DELETE') + ) + ) + or ( + path == '/admin/api/system_logs/clear' + and scope.get('method') == 'DELETE' + ) + or ( + path == '/admin/clear_permission_errors' + and scope.get('method') == 'POST' + ) + or ( + _is_native_admin_users_read_path(path) + and scope.get('method') in ('GET', 'HEAD') + ) + or ( + _is_native_admin_user_create_path(path) + and scope.get('method') == 'PUT' + ) + or ( + _is_native_admin_user_mutation_path(path) + and scope.get('method') in ('PUT', 'DELETE') + ) + or ( + path in { + '/api/admin/features/settings', + '/api/admin/features/challenge', + } + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/admin/features/challenge/test' + and scope.get('method') == 'POST' + ) + or ( + _is_native_admin_ownership_sync_path(path) + and scope.get('method') == 'POST' + ) + or ( + _is_native_download_mutation_path(path) + and scope.get('method') in ('GET', 'HEAD', 'POST') + ) + or ( + _is_native_support_mutation_path(path) + and scope.get('method') == 'POST' + ) + or ( + _is_native_launcher_import_mutation_path(path) + and scope.get('method') == 'POST' + ) + or ( + _is_native_artwork_apply_path(path) + and scope.get('method') == 'POST' + ) + or ( + path == '/api/patch-catalog/attach' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/admin/igdb/settings' + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/arr/config' + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/discover/pins' + and scope.get('method') == 'PUT' + ) + or (path == '/api/admin/themes' and scope.get('method') == 'POST') + or ( + path in {'/admin/themes', '/admin/themes/reset'} + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/admin/themes/delete/') + and scope.get('method') == 'POST' + ) + or (path == '/api/admin/notify-bus/test' and scope.get('method') == 'POST') + or (path == '/api/chat/channels' and scope.get('method') == 'POST') + or ( + path == '/api/emulator-profiles' + and scope.get('method') in ('POST', 'PUT') + ) + or ( + path == '/api/browser-player-settings' + and scope.get('method') in ('POST', 'PUT') + ) + or ( + _is_native_file_type_path(path) + and scope.get('method') in ('POST', 'PUT', 'DELETE') + ) + or (path == '/api/chat/dm' and scope.get('method') == 'POST') + or (path == '/api/chat/emoji' and scope.get('method') == 'POST') + or (path == '/api/admin/chat-emoji' and scope.get('method') == 'POST') + or ( + _is_native_chat_emoji_delete_path(path) + and scope.get('method') == 'DELETE' + ) + or (path == '/api/chat/spaces' and scope.get('method') == 'POST') + or ( + path in ('/api/layouts/detail', '/api/layouts/detail/mine') + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/layouts/detail/mine' + and scope.get('method') == 'DELETE' + ) + or ( + path == '/api/layouts/detail/presets' + and scope.get('method') == 'POST' + ) + or ( + _is_native_layout_preset_path(path) + and scope.get('method') == 'DELETE' + ) + or (path == '/api/chat/spaces/join' and scope.get('method') == 'POST') + or ( + _is_native_chat_space_read_path(path) + and path.endswith('/invites') + and scope.get('method') == 'POST' + ) + or ( + _is_native_chat_space_invite_revoke_path(path) + and scope.get('method') == 'POST' + ) + or ( + _is_native_chat_space_channel_path(path) + and scope.get('method') == 'POST' + ) + or (_is_native_chat_mute_path(path) and scope.get('method') == 'POST') + or (_is_native_chat_messages_path(path) and scope.get('method') == 'POST') + or ( + _is_native_chat_attachment_upload_path(path) + and scope.get('method') == 'POST' + ) + or (_is_native_chat_reaction_path(path) and scope.get('method') == 'POST') + or ( + _is_native_ownership_decision_path(path) + and scope.get('method') == 'POST' + ) + or ( + _is_native_ownership_cancel_path(path) + and scope.get('method') == 'POST' + ) + or ( + _is_native_ownership_action_path(path) + and scope.get('method') in {'POST', 'DELETE'} + ) + or ( + path == '/api/game-servers' + and scope.get('method') == 'POST' + ) + or ( + _is_native_game_server_path(path) + and path != '/api/game-servers' + and scope.get('method') in {'PUT', 'PATCH', 'DELETE'} + ) + or ( + _is_native_emulator_mutation_path(path) + and scope.get('method') in {'POST', 'DELETE'} + ) + or ( + _is_native_game_mods_path(path) + and scope.get('method') in { + 'POST', 'PUT', 'PATCH', 'DELETE', + } + ) + or ( + _is_native_unmatched_mutation_path(path) + and scope.get('method') in {'POST', 'PATCH'} + ) + or ( + _is_native_unmatched_feedback_path(path) + and scope.get('method') == 'POST' + ) + or ( + path in { + '/api/library_tools/manual_identify/list', + '/api/library_tools/propose_leaf_libraries', + '/api/library_tools/import_leaf_libraries/preview', + '/api/library_tools/proposals/scan_roots', + '/api/library_tools/doctor/dry_run', + '/api/library_tools/rename/preview', + '/api/library_tools/proposals/approve', + '/api/library_tools/doctor/write_proposals', + '/api/library_tools/link_existing_game', + '/api/library_tools/rename/apply', + '/api/library_tools/doctor/apply_renames', + '/api/library_tools/backfill_steam_metadata', + '/api/library_tools/check_freshness', + } + and scope.get('method') == 'POST' + ) + or ( + _is_native_library_admin_mutation_path(path) + and scope.get('method') == 'POST' + ) + or ( + path == '/api/admin/newsletters' + and scope.get('method') == 'POST' + ) + or ( + path == '/admin/newsletter' + and scope.get('method') == 'POST' + ) + or ( + path.endswith('/members') + and _is_native_chat_space_read_path(path) + and scope.get('method') == 'POST' + ) + or ( + _is_native_chat_space_member_delete_path(path) + and scope.get('method') == 'DELETE' + ) + or (path == '/api/ai/config' and scope.get('method') == 'PUT') + or ( + path in ('/api/ai/triage', '/api/ai/doctor-notes', '/api/ai/apply-triage') + and scope.get('method') == 'POST' + ) + or ( + path in ( + '/api/admin/challenge-solver/config', + '/api/admin/ambient-lighting/config', + ) + and scope.get('method') == 'PUT' + ) + or ( + path in ( + '/api/admin/challenge-solver/test', + '/api/admin/ambient-lighting/test', + ) + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/api/admin/themes/') + and scope.get('method') == 'DELETE' + ) + or ( + path == '/api/arr/module' + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/quality-profiles' + and scope.get('method') in ('POST', 'PUT') + ) + or ( + path == '/api/quality-profiles/active' + and scope.get('method') in ('POST', 'PUT') + ) + or ( + path == '/api/quality-profiles/score' + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/api/quality-profiles/') + and path.count('/') == 3 + and path.rsplit('/', 1)[-1] not in ('active', 'score') + and scope.get('method') in ('PUT', 'PATCH', 'DELETE') + ) + or ( + path == '/api/admin/igdb/test' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/admin/smtp/settings' + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/admin/server-settings' + and scope.get('method') == 'PUT' + ) + or ( + path == '/api/admin/smtp/test' + and scope.get('method') == 'POST' + ) + or ( + path in {'/admin/smtp_settings', '/admin/smtp_test'} + and scope.get('method') == 'POST' + ) + or ( + path == '/api/admin/reference-sets' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/reference-sets' + and scope.get('method') == 'POST' + ) + or ( + path in ('/api/reference-sets/rehash', '/api/reference-sets/repair-preview') + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/api/reference-sets/') + and path.rsplit('/', 1)[-1].isdigit() + and scope.get('method') == 'DELETE' + ) + or ( + path.startswith('/api/admin/reference-sets/') + and scope.get('method') == 'DELETE' + ) + or ( + path == '/api/arr/indexers' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/arr/download' + and scope.get('method') == 'POST' + ) + or ( + path in ('/api/arr/hardlink/preview', '/api/arr/hardlink/apply') + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/api/hltb/refresh/') + and scope.get('method') == 'POST' + ) + or ( + path == '/api/hltb/bulk-refresh' + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/api/arr/indexers/') + and scope.get('method') in ('PUT', 'PATCH', 'DELETE') + ) + or ( + path in ('/api/arr/indexers/bulk', '/api/arr/indexers/enable-presets') + and scope.get('method') == 'POST' + ) + or ( + path == '/api/licensed-catalog/refresh' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/account/avatar/stock' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/account/avatar' + and scope.get('method') == 'POST' + ) + or ( + path == '/settings_profile_edit' + and scope.get('method') in ('GET', 'HEAD', 'POST') + ) + or ( + path == '/api/account/password' + and scope.get('method') == 'POST' + ) + or ( + path == '/settings_panel' + and scope.get('method') == 'POST' + ) + or ( + path == '/settings_password' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/client/lifecycle' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/client/heartbeat' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/client/commands' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/announcements' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/collections' + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/api/collections/') + and scope.get('method') in ('PATCH', 'DELETE', 'POST', 'PUT') + ) + or ( + path == '/api/requests' + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/api/requests/') + and scope.get('method') in ('PATCH', 'DELETE') + ) + or (path == '/api/social/friends' and scope.get('method') == 'POST') + or ( + path == '/api/notifications/read' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/notifications/preferences' + and scope.get('method') == 'POST' + ) + or ( + path in ('/api/updates/wanted', '/api/updates/wanted/fulfill') + and scope.get('method') == 'POST' + ) + or (path == '/api/updates/scan' and scope.get('method') == 'POST') + or (path == '/api/acquire/download' and scope.get('method') == 'POST') + or ( + path == '/api/events/publish_test' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/move_game_to_library' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/admin/freshness/refresh' + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/api/social/friends/') + and scope.get('method') in ('POST', 'DELETE') + ) + or ( + path.startswith('/api/client/commands/') + and scope.get('method') == 'POST' + ) + or ( + path.startswith('/api/account/invites/') + and scope.get('method') == 'DELETE' + ) + or ( + path == '/api/tokens' + and scope.get('method') == 'POST' + ) + or ( + _is_native_game_read_path(path) + and path.endswith('/input_family') + and scope.get('method') == 'PATCH' + ) + or ( + _is_native_game_read_path(path) + and path.endswith('/freshness/check') + and scope.get('method') == 'POST' + ) + or ( + _is_native_playtime_path(path) + and scope.get('method') == 'POST' + ) + or ( + _is_native_related_media_path(path) + and len(path.strip('/').split('/')) == 4 + and scope.get('method') == 'POST' + ) + or ( + _is_native_game_read_path(path) + and path.endswith('/assists') + and scope.get('method') in ('POST', 'PUT') + ) + or _is_native_manual_identify_mutation_path(path, scope.get('method', '')) + or ( + _is_native_related_media_path(path) + and len(path.strip('/').split('/')) == 5 + and scope.get('method') == 'DELETE' + ) + or ( + path in ('/api/filters/saved', '/api/filters/preview') + and scope.get('method') == 'POST' + ) + or ( + path == '/api/games/batch/favorite' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/games/batch/status' + and scope.get('method') == 'POST' + ) + or ( + path == '/api/games/batch/wishlist' + and scope.get('method') == 'POST' + ) + or (path == '/api/check_username' and scope.get('method') == 'POST') + or ( + path.startswith('/api/admin/chat-emoji/') + and scope.get('method') == 'DELETE' + ) + or ( + _is_native_member_game_state_path(path) + and ( + scope.get('method') in ('GET', 'HEAD') + or ( + scope.get('method') == 'POST' + and path.split('/')[2] in {'toggle_favorite', 'set_game_status'} + ) + ) + ) + or ( + _is_native_vr_profile_path(path) + and scope.get('method') in ('PUT', 'DELETE', 'PATCH') + ) + or ( + path in ('/api/games/batch/freshness/check', '/api/games/batch/refresh_images') + and scope.get('method') == 'POST' + ) + or ( + _is_native_saved_filter_path(path) + and scope.get('method') in ('PUT', 'DELETE') + ) + or ( + path.startswith('/api/tokens/') + and scope.get('method') == 'DELETE' + ) + or _is_native_image_refresh_progress_path(path, scope.get('method', '')) + or _is_native_library_delete_progress_path(path, scope.get('method', '')) + or _is_native_library_delete_sse_path(path, scope.get('method', '')) + or _is_native_scan_job_mutation_path(path, scope.get('method', '')) + or _is_native_library_delete_form_path(path, scope.get('method', '')) + or _is_native_game_delete_path(path, scope.get('method', '')) + or _is_native_unmatched_compat_mutation_path(path, scope.get('method', '')) + ) + ): + await self._native_read_api_app(scope, receive, send) + return + if path == '/api/auth/runtime-bootstrap' and scope['method'] in ('GET', 'HEAD'): + from starlette.requests import Request + from oneirodex.runtime_native.auth_runtime_bootstrap import member_auth_runtime_bootstrap + + response = member_auth_runtime_bootstrap( + self._flask_app, + Request(scope, receive), + database_scope=self._native_database_scope, + runtime='fastapi-session-compat', + ) + if scope['method'] == 'HEAD': + response.body = b'' + await response(scope, receive, send) + return + if (self._native_admin_pilot is not None + and ( + scope['method'] in ('GET', 'HEAD') + or (path == '/admin/manage-downloads' and scope['method'] == 'POST') + ) + and (path in self._native_admin_routes + or is_game_edit_route(path) + or is_game_images_edit_route(path) + or is_numeric_admin_detail(path) + or is_library_edit_route(path))): + await self._native_admin_pilot(scope, receive, send) + return + await self._native_read_api_app(scope, receive, send) + + @staticmethod + def _configured_security_headers(): + """Return the same configured security policy used by Flask responses.""" + from config import Config + from oneirodex.utils.security_headers import security_header_values + + return security_header_values({ + key: getattr(Config, key, None) + for key in ( + 'CSP_ENABLED', 'CSP_ENFORCE', 'HSTS_SECONDS', + 'SESSION_COOKIE_SECURE', + ) + }) + + async def _handle_liveness(self, scope, send): + """Serve the existing GET/HEAD liveness contract as a native ASGI route.""" + from oneirodex import app_version + from oneirodex.runtime_native.health import liveness_payload + + payload = json.dumps( + liveness_payload(app_version), separators=(',', ':'), ensure_ascii=False, + ).encode('utf-8') + b'\n' + headers = { + 'Content-Type': 'application/json', + 'Content-Length': str(len(payload)), + **self._configured_security_headers(), + } + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [(key.lower().encode('ascii'), value.encode('latin-1')) + for key, value in headers.items()], + }) + await send({ + 'type': 'http.response.body', + 'body': b'' if scope.get('method') == 'HEAD' else payload, + 'more_body': False, + }) + + async def _handle_readiness(self, scope, send): + """Run readiness with request-local DB scope without Flask dispatch.""" + from oneirodex.runtime_native.db_session import native_db + from oneirodex.utils.health_probes import build_readiness + from oneirodex.utils.security_headers import security_header_values + config = self._runtime_config or {} + with self._native_database_scope(): + body, status = build_readiness( + config=config, database=native_db, + ) + payload = json.dumps(body, separators=(',', ':'), ensure_ascii=False).encode('utf-8') + b'\n' + header_values = { + 'Content-Type': 'application/json', + 'Content-Length': str(len(payload)), + **security_header_values(config), + } + headers = [(key.lower().encode('ascii'), value.encode('latin-1')) + for key, value in header_values.items()] + await send({'type': 'http.response.start', 'status': status, 'headers': headers}) + await send({ + 'type': 'http.response.body', + 'body': b'' if scope.get('method') == 'HEAD' else payload, + 'more_body': False, + }) -def _is_link_or_escape_refusal(exc: OSError) -> bool: - """True when ``open_plain_file_within`` refused the file itself (a link, - not a regular file, or outside its folder), as opposed to the file being - locked, unreadable or failing with an I/O error.""" - import errno + async def _handle_installed_cores(self, scope, send): + """Serve the public WebRetro core allowlist without Flask startup.""" + from oneirodex.utils.webretro_cores import get_effective_installed_cores + + cores = sorted(get_effective_installed_cores()) + payload = ('var OD_INSTALLED_CORES = ' + json.dumps(cores) + ';\n').encode('utf-8') + headers = { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Content-Length': str(len(payload)), + 'Cache-Control': 'private, max-age=300', + **self._configured_security_headers(), + } + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [(key.lower().encode('ascii'), value.encode('latin-1')) + for key, value in headers.items()], + }) + await send({ + 'type': 'http.response.body', + 'body': b'' if scope.get('method') == 'HEAD' else payload, + 'more_body': False, + }) - if exc.errno in (errno.ELOOP, errno.EINVAL): - return True - return exc.errno == errno.EACCES and exc.strerror == 'file is outside its folder' + async def _handle_app_manifest(self, scope, send): + """Build the install manifest in a request-local DB scope.""" + from oneirodex.utils.pwa_manifest import build_app_manifest + from oneirodex.utils.processors import get_global_settings + + await self._ensure_native_database() + with self._native_database_scope(): + settings = get_global_settings() or {} + manifest = build_app_manifest(settings.get('site_name')) + payload = json.dumps( + manifest, separators=(',', ':'), ensure_ascii=False, sort_keys=True, + ).encode('utf-8') + headers = { + 'Content-Type': 'application/manifest+json', + 'Content-Length': str(len(payload)), + 'Cache-Control': 'public, max-age=3600', + **self._configured_security_headers(), + } + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [(key.lower().encode('ascii'), value.encode('latin-1')) + for key, value in headers.items()], + }) + await send({ + 'type': 'http.response.body', + 'body': b'' if scope.get('method') == 'HEAD' else payload, + 'more_body': False, + }) + async def _handle_offline(self, scope, send): + """Serve the self-contained offline fallback without Flask request state.""" + from oneirodex.utils.asset_version import asset_version + from oneirodex.utils.offline_page import render_offline_page -class LazyASGIApp: - def __init__(self): - self._app = None - self._flask_app = None - self._init_lock = asyncio.Lock() - self._static_root: Path | None = None + static_root = self._static_root or ( + Path(__file__).resolve().parent / 'oneirodex' / 'static' + ) + retry_script = static_root / 'js' / 'od_offline_retry.js' + retry_url = f'/static/js/od_offline_retry.js?v={asset_version(retry_script)}' + payload = render_offline_page(retry_url).encode('utf-8') + headers = { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Length': str(len(payload)), + 'Cache-Control': 'no-cache', + **self._configured_security_headers(), + } + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [(key.lower().encode('ascii'), value.encode('latin-1')) + for key, value in headers.items()], + }) + await send({ + 'type': 'http.response.body', + 'body': b'' if scope.get('method') == 'HEAD' else payload, + 'more_body': False, + }) - async def _ensure_flask(self): - """Create Flask + the WSGI bridge once, safely under concurrent first hits.""" - if self._app is not None: + async def _handle_service_worker(self, scope, send, path: str): + """Serve origin-scoped PWA workers with the same no-cache policy.""" + root = self._static_root or ( + Path(__file__).resolve().parent / 'oneirodex' / 'static' + ) + filename, allowed_scope = ( + ('app-sw.js', '/') if path == '/sw.js' + else ('vr-sw.js', '/vr') + ) + # Service workers have origin-wide authority. Do not let mutable + # library static overrides replace the shipped worker script. + candidate = resolve_static_path(root, f'/static/{filename}') + if candidate is None or not candidate.is_file(): + await self._send_error(send, 404, 'Not Found') + return + try: + payload = await asyncio.to_thread(candidate.read_bytes) + except OSError: + await self._send_error(send, 404, 'Not Found') return - async with self._init_lock: - if self._app is not None: - return - self._flask_app = create_app() - self._static_root = Path(self._flask_app.static_folder or '').resolve() - self._app = WSGIMiddleware(self._flask_app) - async def __call__(self, scope, receive, send): - if scope["type"] == "lifespan": - await self._handle_lifespan(receive, send) - elif scope["type"] == "http": - path = scope["path"] + header_values = { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Content-Length': str(len(payload)), + 'Cache-Control': 'no-cache', + 'Service-Worker-Allowed': allowed_scope, + **self._configured_security_headers(), + } + headers = [(key.lower().encode('ascii'), value.encode('latin-1')) + for key, value in header_values.items()] + await send({'type': 'http.response.start', 'status': 200, 'headers': headers}) + await send({ + 'type': 'http.response.body', + 'body': b'' if scope.get('method') == 'HEAD' else payload, + 'more_body': False, + }) - if path.startswith('/download_zip/') or path.startswith('/api/downloadrom/'): - await self._handle_download(scope, receive, send) - return + async def _handle_favicon(self, scope, send): + """Serve the browser fallback icon with its existing short cache TTL.""" + root = self._static_root or ( + Path(__file__).resolve().parent / 'oneirodex' / 'static' + ) + candidate = resolve_static_path(root, '/static/icons/favicon.ico') + if candidate is None or not candidate.is_file(): + await self._send_error(send, 404, 'Not Found') + return + try: + payload = await asyncio.to_thread(candidate.read_bytes) + except OSError: + await self._send_error(send, 404, 'Not Found') + return - # Long-lived SSE must not run through the WSGI bridge — a single - # sync stream holds a bridge thread for the life of the connection - # and freezes Discover/Admin/API fetches. True of any bridge. - sse_key = path.rstrip('/') or '/' - if sse_key in self._SSE_ROUTES: - cfg = self._SSE_ROUTES[sse_key] - await self._handle_sse( - scope, - receive, - send, - channel=cfg['channel'], - event_types=cfg['event_types'], - restrict_child=cfg['restrict_child'], - ) - return + header_values = { + 'Content-Type': 'image/vnd.microsoft.icon', + 'Content-Length': str(len(payload)), + 'Cache-Control': 'public, max-age=3600', + **self._configured_security_headers(), + } + headers = [(key.lower().encode('ascii'), value.encode('latin-1')) + for key, value in header_values.items()] + await send({'type': 'http.response.start', 'status': 200, 'headers': headers}) + await send({ + 'type': 'http.response.body', + 'body': b'' if scope.get('method') == 'HEAD' else payload, + 'more_body': False, + }) - # Serve /static/* natively. This began as a workaround for the - # asgiref bridge failing under concurrent CSS/JS, which a2wsgi has - # now retired — it stays because the native path streams with - # aiofiles and owns its own cache-control and security headers. - if path.startswith('/static/'): - await self._handle_static(scope, receive, send, path) - return + async def _handle_confirmation_shell(self, scope, send): + """Serve the read-only confirmation document for a fresh visitor.""" + root = self._static_root or ( + Path(__file__).resolve().parent / 'oneirodex' / 'static' + ) + candidate = resolve_static_path(root, '/static/dist/member-auth/auth.html') + if candidate is None: + await self._send_error(send, 503, 'Service Unavailable') + return + try: + payload = await asyncio.to_thread(candidate.read_bytes) + except OSError: + await self._send_error(send, 503, 'Service Unavailable') + return + if payload.count(b'id="member-auth-root"') != 1 or b'' not in payload: + await self._send_error(send, 503, 'Service Unavailable') + return - await self._ensure_flask() - await self._app(scope, receive, send) + header_values = { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Length': str(len(payload)), + 'Cache-Control': 'no-store', + 'Vary': 'Cookie', + **self._configured_security_headers(), + } + headers = [(key.lower().encode('ascii'), value.encode('latin-1')) + for key, value in header_values.items()] + await send({'type': 'http.response.start', 'status': 200, 'headers': headers}) + await send({ + 'type': 'http.response.body', + 'body': b'' if scope.get('method') == 'HEAD' else payload, + 'more_body': False, + }) # path (no trailing slash) → native async SSE config _SSE_ROUTES = { @@ -197,14 +4255,10 @@ def _event_visible(self, event, user_id) -> bool: if payload.get('user_id') is None and not payload.get('game_uuid'): return True # about no member and no game: no lookup needed try: - with self._flask_app.app_context(): - from oneirodex import db - from oneirodex.utils.event_visibility import event_visible_to + from oneirodex.utils.event_visibility import event_visible_to - try: - return event_visible_to(event, user_id) - finally: - db.session.remove() + with self._native_database_scope(self._flask_app): + return event_visible_to(event, user_id) except Exception: # noqa: BLE001 — when in doubt, do not send it return False @@ -212,10 +4266,12 @@ async def _authorize_sse_user(self, user_id, *, restrict_child: bool) -> int | N """Return HTTP error status, or None if the user may open SSE.""" if not user_id: return 401 - with self._flask_app.app_context(): - from oneirodex.utils.rbac import normalize_role + from oneirodex.models import User + from oneirodex.runtime_native.db_session import native_db + from oneirodex.utils.rbac import normalize_role - user = db.session.get(User, user_id) + with self._native_database_scope(self._flask_app): + user = native_db.session.get(User, user_id) if not user: return 401 if restrict_child and normalize_role(getattr(user, 'role', None)) == 'child': @@ -336,7 +4392,6 @@ async def _handle_static(self, scope, receive, send, path: str): await self._send_error(send, 405, "Method Not Allowed") return - await self._ensure_flask() root = self._static_root if root is None or not root.is_dir(): await self._send_error(send, 500, "Static root not configured") @@ -345,7 +4400,20 @@ async def _handle_static(self, scope, receive, send, path: str): # Members' private files under /static/library/ are never static; BIOS # needs a signed-in member. 404 either way, so nothing is confirmed. access = static_access(root, path) - if access == 'private' or (access == 'member' and await self._get_user_from_session(scope) is None): + if access == 'private': + await self._send_error(send, 404, "Not Found") + return + if access == 'member': + # Only this protected static class needs the configured Flask + # session boundary. Public files stay on the pure ASGI path. + await self._ensure_flask() + root = self._static_root + if (root is None or not root.is_dir() + or static_access(root, path) != 'member' + or await self._get_user_from_session(scope) is None): + await self._send_error(send, 404, "Not Found") + return + if root is None: await self._send_error(send, 404, "Not Found") return @@ -392,7 +4460,7 @@ async def _handle_static(self, scope, receive, send, path: str): # of being assumed fresh. normalized = path.replace('\\', '/') is_mutable_theme_asset = '/static/library/themes/' in normalized - # The SPA *entry* bundle is unhashed on purpose so Jinja can link it, + # The SPA *entry* bundle is unhashed on purpose so the page shell can link it, # and the `?v=` on that link makes the script tag fetch fresh. But the # lazy route chunks `import` the entry by its bare path, with no query, # and that unversioned URL was cached for an hour — so after a rebuild @@ -409,9 +4477,8 @@ async def _handle_static(self, scope, receive, send, path: str): else b"public, max-age=3600" ) - # Static is served here, outside the WSGI bridge, so Flask's - # after_request never sees it — these have to be stamped again rather - # than inherited. + # Static is served directly by ASGI, so Flask's after_request never + # sees it — these have to be stamped here instead. # Baseline only, no CSP: webretro.html is a static document whose # Emscripten cores would need 'unsafe-eval' anyway. headers = [ @@ -490,6 +4557,8 @@ async def tracked_send(message): async def _handle_zip_download(self, scope, receive, send, path): """Handle ZIP file downloads""" + from oneirodex.models import DownloadRequest, Game, User + download_id_match = re.match(r'/download_zip/(\d+)', path) if not download_id_match: await self._send_error(send, 400, "Invalid download ID") @@ -502,13 +4571,15 @@ async def _handle_zip_download(self, scope, receive, send, path): await self._send_error(send, 401, "Unauthorized") return - with self._flask_app.app_context(): - user = db.session.get(User, user_id) + from oneirodex.runtime_native.db_session import native_db + + with self._native_database_scope(self._flask_app): + user = native_db.session.get(User, user_id) if not user or 'write:download' in forbidden_scopes_for_role(user.role): await self._send_error(send, 403, "Access denied") return - download_request = db.session.execute( + download_request = native_db.session.execute( select(DownloadRequest).filter_by(id=download_id, user_id=user_id) ).scalars().first() @@ -516,7 +4587,7 @@ async def _handle_zip_download(self, scope, receive, send, path): await self._send_error(send, 404, "Download not found") return - game = db.session.execute( + game = native_db.session.execute( select(Game).filter_by(uuid=download_request.game_uuid) ).scalars().first() if not game or not user_can_access_game(user, game): @@ -593,6 +4664,9 @@ async def _handle_zip_download(self, scope, receive, send, path): async def _handle_rom_download(self, scope, receive, send, path): """Handle ROM file downloads for emulator""" + from oneirodex.models import Game, User + from oneirodex.utils.rom_archive import ArchiveRomError + rom_match = re.match(r'/api/downloadrom/([a-f0-9-]+)', path) if not rom_match: await self._send_error(send, 400, "Invalid game UUID") @@ -616,8 +4690,10 @@ async def _handle_rom_download(self, scope, receive, send, path): await self._send_error(send, 401, "Unauthorized") return - with self._flask_app.app_context(): - game = db.session.execute(select(Game).filter_by(uuid=game_uuid)).scalars().first() + from oneirodex.runtime_native.db_session import native_db + + with self._native_database_scope(self._flask_app): + game = native_db.session.execute(select(Game).filter_by(uuid=game_uuid)).scalars().first() if not game: log_system_event( @@ -628,7 +4704,7 @@ async def _handle_rom_download(self, scope, receive, send, path): await self._send_error(send, 404, "Game not found") return - user = db.session.get(User, user_id) + user = native_db.session.get(User, user_id) if not user or not user_can_access_game(user, game): log_system_event( f"ROM download blocked by library ACL for user_id={user_id} game={game_uuid[:8]}...", @@ -738,16 +4814,35 @@ async def _get_user_id(self, scope): auth_header = headers.get(b"authorization", b"").decode("utf-8") if auth_header.lower().startswith("bearer "): await self._ensure_flask() - with self._flask_app.app_context(): - from oneirodex.utils.api_tokens import verify_bearer_token - - raw = auth_header.split(" ", 1)[1].strip() - user, token = verify_bearer_token(raw) + from starlette.requests import Request + + from oneirodex.runtime_native.admin_pilot import _native_auth_backend + from oneirodex.runtime_native.request_auth import request_auth_context + + find_user, find_token, commit_token_touch = _native_auth_backend(self._flask_app) + # The ASGI download adapter historically gives an explicit Bearer + # credential precedence over cookies; don't let a parallel browser + # session change which credential is evaluated. + bearer_scope = { + **scope, + 'type': 'http', + 'headers': [ + (key, value) for key, value in scope.get('headers', []) + if key.lower() != b'cookie' + ], + } + with self._native_database_scope(self._flask_app): + auth = request_auth_context( + Request(bearer_scope), self._flask_app.config, + find_user=find_user, find_token=find_token, + commit_token_touch=commit_token_touch, + login_manager=getattr(self._flask_app, 'login_manager', None), + ) + user = auth.user if ( user - and token - and token.has_scope('write:download') - and 'write:download' not in forbidden_scopes_for_role(user.role) + and auth.token + and auth.has_scope('write:download') ): return user.id return None @@ -756,53 +4851,38 @@ async def _get_user_id(self, scope): async def _get_user_from_session(self, scope): """Extract user ID from Flask session cookie""" - headers = dict(scope.get("headers", [])) - cookie_header = headers.get(b"cookie", b"").decode("utf-8") - - if not cookie_header: - return None - - cookies = {} - for cookie in cookie_header.split(';'): - if '=' in cookie: - name, value = cookie.strip().split('=', 1) - cookies[name] = value - - session_cookie = cookies.get('session') - if not session_cookie: - return None - try: await self._ensure_flask() - with self._flask_app.app_context(): - from flask import Request - from flask.sessions import SecureCookieSessionInterface - - session_interface = SecureCookieSessionInterface() - environ = { - 'REQUEST_METHOD': 'GET', - 'PATH_INFO': '/', - 'SERVER_NAME': 'localhost', - 'SERVER_PORT': '5000', - 'HTTP_COOKIE': cookie_header, - 'wsgi.url_scheme': 'http', - } - request = Request(environ) - session_data = session_interface.open_session(self._flask_app, request) - if session_data: - user_id = session_data.get('_user_id') - if user_id: - # The same check as Flask-Login's loader: a disabled - # account or a changed password ends the session here too. - from oneirodex.utils.auth import load_user - - user = load_user(user_id) - return user.id if user is not None else None + from starlette.requests import Request + + from oneirodex.runtime_native.admin_pilot import _native_auth_backend + from oneirodex.runtime_native.auth_resolver import load_active_user_id + from oneirodex.runtime_native.request_auth import _lifetime_seconds + from oneirodex.runtime_native.session_compat import load_session_cookie + + request_scope = {**scope, 'type': 'http'} + request = Request(request_scope) + config = self._flask_app.config + session_data = load_session_cookie( + request.cookies.get(str(config.get('SESSION_COOKIE_NAME') or 'session')), + config.get('SECRET_KEY'), + max_age=_lifetime_seconds(config.get('PERMANENT_SESSION_LIFETIME', 31_536_000)), + fallback_secret_keys=config.get('SECRET_KEY_FALLBACKS') or (), + ) + user_id = session_data.get('_user_id') + if user_id is None: return None + find_user, _find_token, _commit_token_touch = _native_auth_backend(self._flask_app) + with self._native_database_scope(self._flask_app): + user = load_active_user_id( + str(user_id), find_user, allow_legacy_id=bool(config.get('TESTING')), + ) + return user.id if user is not None else None + except Exception as e: log_system_event( - f"Error parsing Flask session cookie: {str(e)}", + f"Error parsing signed session for download authorization: {type(e).__name__}", event_type='security', event_level='warning', ) @@ -998,7 +5078,12 @@ async def _handle_lifespan(self, receive, send): from oneirodex.utils.shutdown import request_shutdown - request_shutdown() + try: + request_shutdown() + finally: + if self._native_sqlalchemy_engine is not None: + self._native_sqlalchemy_engine.dispose() + self._native_sqlalchemy_engine = None print("🛑 ASGI lifespan shutdown initiated") await send({"type": "lifespan.shutdown.complete"}) except Exception as e: diff --git a/clients/desktop/README.md b/clients/desktop/README.md index d4d594b45..016acb037 100644 --- a/clients/desktop/README.md +++ b/clients/desktop/README.md @@ -73,6 +73,8 @@ For all three platforms in one go, use [`scripts/build-installers.sh`](../../scr Legacy plaintext `token` fields in `config.json` are migrated into the secure store on next load and scrubbed from the file. `KeychainAdapter` in `src/auth.ts` is wired via `src/keychain.ts` → Tauri `secure_store_*` commands. +Both desktop build flavors apply a WebView Content Security Policy from their Tauri configuration. Scripts are limited to bundled app sources; objects and embedded frames are blocked. The policy allows the configured Oneirodex server connection and remote library images, so server URLs and cover art continue to work. + ## Download & install pipeline The desktop client downloads **DRM-free library files from your Oneirodex server only** (no Steam/GOG acquisition). @@ -93,7 +95,7 @@ Example (Windows): ### Server flow -1. **Initiate:** `POST /api/downloads/games/` (Bearer token) — mirrors web `/download_game/` and returns `{ download_id, stream_url }`. +1. **Initiate:** `POST /api/downloads/games/` (Bearer token) returns `{ download_id, stream_url }`. Browser sessions use the same write endpoint with CSRF protection. The legacy `/download_game/` and `/download_other/...` paths are POST-only; GET and HEAD return 405 without changing download state. 2. **Stream:** `GET /download_zip/` with the same Bearer token — same path the web UI uses after adding a game to the download basket. 3. **Install:** extracts the local ZIP into `installs//` via a Rust `zip` command, shallow-searches for a likely `.exe`, and stores paths in `installs.json`. 4. **Uninstall:** deletes the extract folder (and archive by default) and transitions lifecycle state. diff --git a/clients/desktop/package.json b/clients/desktop/package.json index 52e5c290d..d6415dc70 100644 --- a/clients/desktop/package.json +++ b/clients/desktop/package.json @@ -1,7 +1,7 @@ { "name": "oneirodex-desktop", "private": true, - "version": "1.1.8", + "version": "1.1.9", "description": "Oneirodex desktop companion (Tauri 2)", "type": "module", "engines": { diff --git a/clients/desktop/src-tauri/Cargo.lock b/clients/desktop/src-tauri/Cargo.lock index 91fc5b712..a148a0cde 100644 --- a/clients/desktop/src-tauri/Cargo.lock +++ b/clients/desktop/src-tauri/Cargo.lock @@ -2528,7 +2528,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneirodex-desktop" -version = "1.1.8" +version = "1.1.9" dependencies = [ "keyring", "serde", diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml index 10ec90135..7400a3408 100644 --- a/clients/desktop/src-tauri/Cargo.toml +++ b/clients/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oneirodex-desktop" -version = "1.1.8" +version = "1.1.9" description = "Oneirodex desktop companion" authors = ["Oneirodex"] edition = "2021" diff --git a/clients/desktop/src-tauri/capabilities/default.json b/clients/desktop/src-tauri/capabilities/default.json index 24746bc50..d5aaa4066 100644 --- a/clients/desktop/src-tauri/capabilities/default.json +++ b/clients/desktop/src-tauri/capabilities/default.json @@ -74,6 +74,8 @@ "allow-run-flips-apply", + "allow-inspect-staged-mod", + "allow-apply-staged-mod" ] diff --git a/clients/desktop/src-tauri/permissions/allow-inspect-staged-mod.toml b/clients/desktop/src-tauri/permissions/allow-inspect-staged-mod.toml new file mode 100644 index 000000000..957c33ac0 --- /dev/null +++ b/clients/desktop/src-tauri/permissions/allow-inspect-staged-mod.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "allow-inspect-staged-mod" +description = "Allows validating staged mod archive size and entry counts before applying them." +commands.allow = ["inspect_staged_mod"] diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 395d4136f..0fc15c5a1 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -123,6 +123,15 @@ fn resolve_subdir(app: &tauri::AppHandle, subdir: &str) -> Result Result<(), String> { + const ALLOWED: &[&str] = &["downloads", "installs", "mods", "patches", "cheats"]; + if ALLOWED.contains(&subdir) { + Ok(()) + } else { + Err("Unsupported application directory".into()) + } +} + fn installs_root(app: &tauri::AppHandle) -> Result { resolve_subdir(app, "installs") } @@ -654,6 +663,7 @@ fn user_facing_path(path: &str) -> String { #[tauri::command] fn get_app_subdir(app: tauri::AppHandle, subdir: String) -> Result { + validate_app_subdir(&subdir)?; resolve_subdir(&app, &subdir).map(|path| path.to_string_lossy().into_owned()) } @@ -678,8 +688,9 @@ fn append_file_bytes(app: tauri::AppHandle, path: String, bytes: Vec) -> Res use std::fs::OpenOptions; let downloads = resolve_subdir(&app, "downloads")?; + let mods = resolve_subdir(&app, "mods")?; let file_path = PathBuf::from(&path); - ensure_path_under_root(&file_path, &downloads)?; + ensure_path_under_any_root(&file_path, &[&downloads, &mods])?; if let Some(parent) = file_path.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; } @@ -891,10 +902,22 @@ pub struct FlipsApplyResult { } #[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] pub struct ApplyStagedModResult { pub applied: u32, + pub expanded_bytes: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StagedModStats { + pub entry_count: u64, + pub expanded_bytes: u64, } +const MAX_MOD_ARCHIVE_ENTRIES: u64 = 50_000; +const MAX_MOD_ARCHIVE_EXPANDED_BYTES: u64 = 4 * 1024 * 1024 * 1024; + fn sanitize_mod_filename(name: &str) -> String { let trimmed = name.trim().replace('\\', "/"); let base = trimmed.rsplit('/').next().unwrap_or("mod.bin"); @@ -922,6 +945,183 @@ fn is_mod_zip_path(path: &Path) -> bool { .is_some_and(|ext| ext.eq_ignore_ascii_case("zip")) } +fn staged_mod_stats_with_limits( + path: &Path, + max_entries: u64, + max_expanded_bytes: u64, +) -> Result { + if path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("7z")) + { + return Err( + "7z mod archives are not supported; use a ZIP archive or a direct mod file".into(), + ); + } + if is_mod_zip_path(path) { + let file = File::open(path).map_err(|error| error.to_string())?; + let mut zip = ZipArchive::new(file).map_err(|error| error.to_string())?; + if zip.len() as u64 > max_entries { + return Err(format!("Mod archive exceeds the {max_entries}-entry limit")); + } + let mut expanded_bytes = 0_u64; + for index in 0..zip.len() { + let entry = zip.by_index(index).map_err(|error| error.to_string())?; + expanded_bytes = expanded_bytes + .checked_add(entry.size()) + .ok_or_else(|| "Mod archive expanded size overflow".to_string())?; + if expanded_bytes > max_expanded_bytes { + return Err(format!( + "Mod archive exceeds the {} GiB expanded-size limit", + max_expanded_bytes / (1024 * 1024 * 1024) + )); + } + } + Ok(StagedModStats { + entry_count: zip.len() as u64, + expanded_bytes, + }) + } else { + let metadata = fs::metadata(path).map_err(|error| error.to_string())?; + if metadata.len() > max_expanded_bytes { + return Err(format!( + "Mod file exceeds the {} GiB size limit", + max_expanded_bytes / (1024 * 1024 * 1024) + )); + } + Ok(StagedModStats { + entry_count: 1, + expanded_bytes: metadata.len(), + }) + } +} + +fn staged_mod_stats(path: &Path) -> Result { + staged_mod_stats_with_limits( + path, + MAX_MOD_ARCHIVE_ENTRIES, + MAX_MOD_ARCHIVE_EXPANDED_BYTES, + ) +} + +/// Resolve a ZIP member beneath an existing install root without following a +/// symlink that escapes that root. Mods are extracted into an existing game +/// tree, so lexical `enclosed_name()` checks alone do not prevent writes +/// through a pre-existing symlink. +fn safe_mod_entry_path( + root: &Path, + relative: &Path, + create_directory: bool, +) -> Result { + let canonical_root = root.canonicalize().map_err(|error| error.to_string())?; + let components: Vec<_> = relative.components().collect(); + if components.is_empty() + || components + .iter() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err("Invalid mod archive entry path".into()); + } + + let mut current = canonical_root.clone(); + for (index, component) in components.iter().enumerate() { + current.push(component); + let leaf = index + 1 == components.len(); + if !leaf || create_directory { + if !current.exists() { + fs::create_dir(¤t).map_err(|error| error.to_string())?; + } + let resolved = current.canonicalize().map_err(|error| error.to_string())?; + if !resolved.starts_with(&canonical_root) || !resolved.is_dir() { + return Err("Mod archive entry escapes the install directory".into()); + } + current = resolved; + } else if fs::symlink_metadata(¤t).is_ok() { + let resolved = current.canonicalize().map_err(|error| error.to_string())?; + if !resolved.starts_with(&canonical_root) { + return Err("Mod archive entry escapes the install directory".into()); + } + current = resolved; + } + } + Ok(current) +} + +fn extract_staged_mod_zip( + zip: &mut ZipArchive, + destination_root: &Path, +) -> Result<(u32, u64), String> { + if zip.len() as u64 > MAX_MOD_ARCHIVE_ENTRIES { + return Err(format!( + "Mod archive exceeds the {MAX_MOD_ARCHIVE_ENTRIES}-entry limit" + )); + } + let mut declared_bytes = 0_u64; + for index in 0..zip.len() { + let entry = zip.by_index(index).map_err(|error| error.to_string())?; + declared_bytes = declared_bytes + .checked_add(entry.size()) + .ok_or_else(|| "Mod archive expanded size overflow".to_string())?; + if declared_bytes > MAX_MOD_ARCHIVE_EXPANDED_BYTES { + return Err(format!( + "Mod archive exceeds the {} GiB expanded-size limit", + MAX_MOD_ARCHIVE_EXPANDED_BYTES / (1024 * 1024 * 1024) + )); + } + } + let mut applied = 0; + let mut expanded_bytes = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + for index in 0..zip.len() { + let mut entry = zip.by_index(index).map_err(|error| error.to_string())?; + let relative = match entry.enclosed_name() { + Some(path) => path, + None => continue, + }; + let is_directory = entry.name().ends_with('/'); + let entry_path = safe_mod_entry_path(destination_root, &relative, is_directory)?; + if is_directory { + continue; + } + let mut out = File::create(&entry_path).map_err(|error| error.to_string())?; + loop { + use std::io::Read; + let read = entry.read(&mut buffer).map_err(|error| error.to_string())?; + if read == 0 { + break; + } + expanded_bytes = expanded_bytes + .checked_add(read as u64) + .ok_or_else(|| "Mod archive expanded size overflow".to_string())?; + if expanded_bytes > MAX_MOD_ARCHIVE_EXPANDED_BYTES { + return Err(format!( + "Mod archive exceeds the {} GiB expanded-size limit", + MAX_MOD_ARCHIVE_EXPANDED_BYTES / (1024 * 1024 * 1024) + )); + } + out.write_all(&buffer[..read]) + .map_err(|error| error.to_string())?; + } + applied += 1; + } + Ok((applied, expanded_bytes)) +} + +#[tauri::command] +fn inspect_staged_mod( + app: tauri::AppHandle, + source_path: String, +) -> Result { + let mods = resolve_subdir(&app, "mods")?; + let source = PathBuf::from(&source_path); + ensure_path_under_root(&source, &mods)?; + if !source.is_file() { + return Err("Staged mod file not found".into()); + } + staged_mod_stats(&source) +} + /// Copy a staged mod file or extract a zip into the game install directory (path-safe). #[tauri::command] fn apply_staged_mod( @@ -940,30 +1140,11 @@ fn apply_staged_mod( return Err(format!("Staged mod not found: {source_path}")); } - let mut applied: u32 = 0; - if is_mod_zip_path(&source) { + let stats = staged_mod_stats(&source)?; + let (applied, expanded_bytes) = if is_mod_zip_path(&source) { let file = File::open(&source).map_err(|error| error.to_string())?; let mut zip = ZipArchive::new(file).map_err(|error| error.to_string())?; - for index in 0..zip.len() { - let mut entry = zip.by_index(index).map_err(|error| error.to_string())?; - let entry_path = match entry.enclosed_name() { - Some(path) => destination_root.join(path), - None => continue, - }; - if !entry_path.starts_with(&destination_root) { - continue; - } - if entry.name().ends_with('/') { - fs::create_dir_all(&entry_path).map_err(|error| error.to_string())?; - continue; - } - if let Some(parent) = entry_path.parent() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } - let mut out = File::create(&entry_path).map_err(|error| error.to_string())?; - copy(&mut entry, &mut out).map_err(|error| error.to_string())?; - applied += 1; - } + extract_staged_mod_zip(&mut zip, &destination_root)? } else { let file_name = source .file_name() @@ -976,10 +1157,13 @@ fn apply_staged_mod( fs::create_dir_all(parent).map_err(|error| error.to_string())?; } fs::copy(&source, &target).map_err(|error| error.to_string())?; - applied = 1; - } + (1, stats.expanded_bytes) + }; - Ok(ApplyStagedModResult { applied }) + Ok(ApplyStagedModResult { + applied, + expanded_bytes, + }) } #[tauri::command] @@ -1420,6 +1604,7 @@ pub fn run() { rename_path, get_flips_path, run_flips_apply, + inspect_staged_mod, apply_staged_mod, ]) .run(tauri::generate_context!()) @@ -1435,6 +1620,28 @@ mod tests { use tempfile::tempdir; use zip::write::{SimpleFileOptions, ZipWriter}; + #[test] + fn app_subdir_allows_only_managed_directory_names() { + for name in ["downloads", "installs", "mods", "patches", "cheats"] { + assert!( + validate_app_subdir(name).is_ok(), + "expected {name} to be allowed" + ); + } + for name in [ + "../escape", + "..\\escape", + "/tmp/escape", + "C:\\escape", + "unknown", + ] { + assert!( + validate_app_subdir(name).is_err(), + "expected {name} to be rejected" + ); + } + } + /// The reveal tests below run with an empty trust list (nothing on the /// network is trusted); the trusted-share cases have their own section. fn validate_reveal_path(path: &str) -> Result<(PathBuf, &'static str), String> { @@ -1777,6 +1984,95 @@ mod tests { assert!(!dest.join("stale.txt").exists()); } + #[cfg(unix)] + #[test] + fn staged_mod_zip_refuses_to_write_through_a_symlink_outside_install_root() { + use std::os::unix::fs::symlink; + + let work = tempdir().unwrap(); + let install = work.path().join("installs/game"); + let outside = work.path().join("outside"); + fs::create_dir_all(&install).unwrap(); + fs::create_dir_all(&outside).unwrap(); + symlink(&outside, install.join("linked")).unwrap(); + + let archive = work.path().join("mod.zip"); + write_zip(&archive, &[("linked/pwned.txt", b"outside")]); + let file = fs::File::open(&archive).unwrap(); + let mut zip = ZipArchive::new(file).unwrap(); + + let err = extract_staged_mod_zip(&mut zip, &install).unwrap_err(); + + assert!(err.contains("escapes the install directory"), "got: {err}"); + assert!(!outside.join("pwned.txt").exists()); + } + + #[cfg(unix)] + #[test] + fn staged_mod_zip_refuses_to_create_a_dangling_symlink_target_outside_install_root() { + use std::os::unix::fs::symlink; + + let work = tempdir().unwrap(); + let install = work.path().join("installs/game"); + let outside_target = work.path().join("outside/pwned.txt"); + fs::create_dir_all(&install).unwrap(); + fs::create_dir_all(outside_target.parent().unwrap()).unwrap(); + symlink(&outside_target, install.join("dangling.txt")).unwrap(); + + let archive = work.path().join("mod.zip"); + write_zip(&archive, &[("dangling.txt", b"outside")]); + let file = fs::File::open(&archive).unwrap(); + let mut zip = ZipArchive::new(file).unwrap(); + + assert!(extract_staged_mod_zip(&mut zip, &install).is_err()); + assert!(!outside_target.exists()); + } + + #[test] + fn staged_mod_zip_writes_nested_entries_inside_install_root() { + let work = tempdir().unwrap(); + let install = work.path().join("installs/game"); + fs::create_dir_all(&install).unwrap(); + let archive = work.path().join("mod.zip"); + write_zip(&archive, &[("nested/mod.bin", b"payload")]); + let file = fs::File::open(&archive).unwrap(); + let mut zip = ZipArchive::new(file).unwrap(); + + assert_eq!(extract_staged_mod_zip(&mut zip, &install).unwrap(), (1, 7)); + assert_eq!( + fs::read(install.join("nested/mod.bin")).unwrap(), + b"payload" + ); + } + + #[test] + fn staged_mod_stats_enforces_entry_and_expanded_size_limits() { + let work = tempdir().unwrap(); + let archive = work.path().join("mod.zip"); + write_zip(&archive, &[("one.bin", b"1234"), ("two.bin", b"5678")]); + + let entry_error = staged_mod_stats_with_limits(&archive, 1, 8).unwrap_err(); + assert!(entry_error.contains("1-entry limit"), "got: {entry_error}"); + let size_error = staged_mod_stats_with_limits(&archive, 2, 7).unwrap_err(); + assert!( + size_error.contains("expanded-size limit"), + "got: {size_error}" + ); + + let stats = staged_mod_stats_with_limits(&archive, 2, 8).unwrap(); + assert_eq!(stats.entry_count, 2); + assert_eq!(stats.expanded_bytes, 8); + } + + #[test] + fn staged_mod_stats_refuses_unsupported_7z_archives() { + let work = tempdir().unwrap(); + let archive = work.path().join("mod.7z"); + fs::write(&archive, b"not extracted").unwrap(); + let error = staged_mod_stats(&archive).unwrap_err(); + assert!(error.contains("7z mod archives are not supported")); + } + // --------------------------------------------------------------- // strictly-under-root — destructive operations never take a root // --------------------------------------------------------------- diff --git a/clients/desktop/src-tauri/tauri.conf.json b/clients/desktop/src-tauri/tauri.conf.json index 037170c3b..16f8dfc3e 100644 --- a/clients/desktop/src-tauri/tauri.conf.json +++ b/clients/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Oneirodex", - "version": "1.1.8", + "version": "1.1.9", "identifier": "com.oneirodex.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -20,7 +20,18 @@ } ], "security": { - "csp": null, + "csp": { + "default-src": "'self'", + "base-uri": "'self'", + "object-src": "'none'", + "frame-src": "'none'", + "form-action": "'self'", + "script-src": "'self'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' asset: http: https: blob: data:", + "font-src": "'self' asset: data:", + "connect-src": "ipc: http://ipc.localhost http: https: ws: wss:" + }, "capabilities": ["default", "social"] } }, diff --git a/clients/desktop/src-tauri/tauri.thin.conf.json b/clients/desktop/src-tauri/tauri.thin.conf.json index 80949ddd9..2126db494 100644 --- a/clients/desktop/src-tauri/tauri.thin.conf.json +++ b/clients/desktop/src-tauri/tauri.thin.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OneirodexThin", - "version": "1.1.8", + "version": "1.1.9", "identifier": "com.oneirodex.thin", "build": { "beforeDevCommand": "npm run build:thin", @@ -19,7 +19,18 @@ } ], "security": { - "csp": null, + "csp": { + "default-src": "'self'", + "base-uri": "'self'", + "object-src": "'none'", + "frame-src": "'none'", + "form-action": "'self'", + "script-src": "'self'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' asset: http: https: blob: data:", + "font-src": "'self' asset: data:", + "connect-src": "ipc: http://ipc.localhost http: https: ws: wss:" + }, "capabilities": ["thin-main", "thin-library", "social"] } }, diff --git a/clients/desktop/src/app.ts b/clients/desktop/src/app.ts index ebb0d4f90..59e36745c 100644 --- a/clients/desktop/src/app.ts +++ b/clients/desktop/src/app.ts @@ -90,7 +90,9 @@ let els: { lifecyclePanel: HTMLElement } function setStatus(message: string, tone: 'info' | 'error' | 'success' = 'info'): void { - els.status.textContent = message + if (els.status.textContent !== message) { + els.status.textContent = message + } els.status.dataset.tone = tone } function setGameActivity(gameUuid: string, message: string): void { @@ -157,7 +159,7 @@ async function renderLifecyclePanel(): Promise { ? `

exe: ${escapeHtml(install.exePath)}
` : '' const revealBit = install?.extractPath - ? `` + ? `` : '' return `
  • ${escapeHtml(title)} — ${escapeHtml(record.state)}
    @@ -207,11 +209,12 @@ function renderLibrary(): void { const disabled = isBusy || blocked ? 'disabled' : '' const updateClass = action === 'update' && state === 'update_available' ? ' action-btn--update' : '' - return `` + const label = action.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase()) + return `` }) .join('') const playButton = canLaunchGame(state) - ? `` + ? `` : '' const modBlocked = modApplyBlockedReason({ connectionOnline: connectionMode === 'online', @@ -225,10 +228,10 @@ function renderLibrary(): void { const modDisabled = isBusy || Boolean(modBlocked) ? 'disabled' : '' const modButton = modsTrackingEnabled && (gamesWithEnabledMods.has(uuid) || canLaunchGame(state)) - ? `` + ? `` : '' const revealButton = canLaunchGame(state) - ? `` + ? `` : '' const activityHtml = activity ? `

    ${escapeHtml(activity)}

    ` : '' return ` @@ -747,7 +750,9 @@ function bindTrustedShares(root: HTMLElement): void { const status = root.querySelector('#trusted-shares-status')! const saveBtn = root.querySelector('#trusted-shares-save')! const show = (message: string, tone: 'info' | 'error' | 'success') => { - status.textContent = message + if (status.textContent !== message) { + status.textContent = message + } status.dataset.tone = tone } void loadTrustedShares() @@ -796,10 +801,10 @@ export async function mountApp(root: HTMLElement): Promise { API token - - + + -

    +

    Trusted network shares

    @@ -808,8 +813,8 @@ export async function mountApp(root: HTMLElement): Promise { Shares - -

    + +

    Library preview

    diff --git a/clients/desktop/src/apply_mods.test.ts b/clients/desktop/src/apply_mods.test.ts index 125fe5e36..b361931d0 100644 --- a/clients/desktop/src/apply_mods.test.ts +++ b/clients/desktop/src/apply_mods.test.ts @@ -24,6 +24,7 @@ import { requiredLoaders, fetchModsSummaryGameUuids, isModArchiveFilename, + isUnsupportedModArchiveFilename, kickoffApplyModPack, modApplyBlockedReason, modApplyUiHint, @@ -63,7 +64,8 @@ describe('apply_mods helpers', () => { it('detects mod archives', () => { expect(isModArchiveFilename('pack.zip')).toBe(true) - expect(isModArchiveFilename('pack.7Z')).toBe(true) + expect(isModArchiveFilename('pack.7Z')).toBe(false) + expect(isUnsupportedModArchiveFilename('pack.7Z')).toBe(true) expect(isModArchiveFilename('readme.txt')).toBe(false) }) @@ -249,6 +251,10 @@ describe('apply_mods helpers', () => { expect(result.mods[0].source_url).toBe('https://x/hd.zip') expect(result.mods[0].loader).toBe('') expect(result.default_loader).toBe('') + expect(fetchImpl).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ redirect: 'error' }), + ) }) it('reads loader on rows and the pack default, and says which loaders are needed (INSP-36)', async () => { @@ -322,19 +328,20 @@ describe('apply_mods helpers', () => { expect(uuids.gameUuids.has('g1')).toBe(true) expect(uuids.gameUuids.has('g2')).toBe(false) expect(uuids.trackingEnabled).toBe(true) + expect(fetchImpl).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ redirect: 'error' }), + ) }) - it('stageModFromUrl writes under app_data/mods via write_file_bytes', async () => { + it('stageModFromUrl streams bounded chunks under app_data/mods', async () => { vi.mocked(invoke).mockImplementation(async (command: string) => { if (command === 'get_app_subdir') { return '/appdata/mods' } return undefined }) - const fetchImpl = vi.fn(async () => ({ - ok: true, - arrayBuffer: async () => new TextEncoder().encode('PK').buffer, - })) as unknown as typeof fetch + const fetchImpl = vi.fn(async () => new Response(new TextEncoder().encode('PK'))) const result = await stageModFromUrl({ gameUuid: 'game-42', @@ -349,10 +356,14 @@ describe('apply_mods helpers', () => { fetchImpl, }) - expect(result).toEqual({ ok: true, path: '/appdata/mods/game-42/hd/hd.zip' }) + expect(result).toEqual({ ok: true, path: '/appdata/mods/game-42/hd/hd.zip', bytes: 2 }) expect(invoke).toHaveBeenCalledWith('write_file_bytes', { path: '/appdata/mods/game-42/hd/hd.zip', - bytes: expect.any(Array), + bytes: [], + }) + expect(invoke).toHaveBeenCalledWith('append_file_bytes', { + path: '/appdata/mods/game-42/hd/hd.zip', + bytes: expect.any(Uint8Array), }) }) @@ -368,10 +379,7 @@ describe('apply_mods helpers', () => { requires: [], }) const okFetch = () => - vi.fn(async () => ({ - ok: true, - arrayBuffer: async () => new TextEncoder().encode('PK').buffer, - })) + vi.fn(async () => new Response(new TextEncoder().encode('PK'))) beforeEach(() => { vi.mocked(invoke).mockImplementation(async (command: string) => @@ -392,6 +400,37 @@ describe('apply_mods helpers', () => { expect(invoke).not.toHaveBeenCalledWith('write_file_bytes', expect.anything()) }) + it('refuses unsupported 7z archives before downloading', async () => { + const fetchImpl = okFetch() + const result = await stageModFromUrl({ + gameUuid: 'game-42', + mod: modAt('https://cdn.example.com/hd.7z'), + fetchImpl, + }) + expect(result).toEqual({ + ok: false, + error: expect.stringContaining('7z archives are not supported'), + }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('stops a streaming download at the caller remaining-byte limit and deletes the partial file', async () => { + const limit = 4 * 1024 * 1024 + const fetchImpl = vi.fn(async () => new Response(new Uint8Array(limit + 1))) + const result = await stageModFromUrl({ + gameUuid: 'game-42', + mod: modAt('https://cdn.example.com/hd.zip'), + fetchImpl, + maxBytes: limit, + }) + expect(result.ok).toBe(false) + expect(result.ok === false && result.error).toContain('4 MiB pack limit') + expect(invoke).toHaveBeenCalledWith('remove_path', { + path: '/appdata/mods/game-42/hd/hd.zip', + }) + expect(invoke).not.toHaveBeenCalledWith('inspect_staged_mod', expect.anything()) + }) + it('refuses non-http(s) schemes and junk', async () => { for (const source of [ 'ftp://cdn.example.com/hd.zip', @@ -433,7 +472,8 @@ describe('apply_mods helpers', () => { ok: true, redirected: true, url: 'http://evil.example.com/hd.zip', - arrayBuffer: async () => new TextEncoder().encode('PK').buffer, + body: new Response(new TextEncoder().encode('PK')).body, + headers: new Headers(), })) const result = await stageModFromUrl({ gameUuid: 'game-42', @@ -449,7 +489,8 @@ describe('apply_mods helpers', () => { ok: true, redirected: true, url: 'https://objects.example.net/hd.zip', - arrayBuffer: async () => new TextEncoder().encode('PK').buffer, + body: new Response(new TextEncoder().encode('PK')).body, + headers: new Headers(), })) const result = await stageModFromUrl({ gameUuid: 'game-42', @@ -466,7 +507,10 @@ describe('apply_mods helpers', () => { return '/appdata/mods' } if (command === 'apply_staged_mod') { - return { applied: 3 } + return { applied: 3, expandedBytes: 10 } + } + if (command === 'inspect_staged_mod') { + return { entryCount: 3, expandedBytes: 10 } } return undefined }) @@ -491,7 +535,8 @@ describe('apply_mods helpers', () => { } return { ok: true, - arrayBuffer: async () => new TextEncoder().encode('PK').buffer, + body: new Response(new TextEncoder().encode('PK')).body, + headers: new Headers(), } }) as unknown as typeof fetch @@ -514,6 +559,49 @@ describe('apply_mods helpers', () => { }) }) + it('rejects an oversized mod pack before applying any staged files', async () => { + vi.mocked(invoke).mockImplementation(async (command: string) => { + if (command === 'get_app_subdir') return '/appdata/mods' + if (command === 'inspect_staged_mod') { + return { entryCount: 1, expandedBytes: 3 * 1024 * 1024 * 1024 } + } + if (command === 'apply_staged_mod') return { applied: 1, expandedBytes: 0 } + return undefined + }) + + const fetchImpl = vi.fn(async (url: string) => { + if (url.includes('/mods')) { + return { + ok: true, + json: async () => ({ + enabled: true, + mods: [ + { id: 'm1', name: 'One', source_url: 'https://cdn.example/one.zip', enabled: true, load_order: 0 }, + { id: 'm2', name: 'Two', source_url: 'https://cdn.example/two.zip', enabled: true, load_order: 1 }, + ], + }), + } + } + return { + ok: true, + body: new Response(new TextEncoder().encode('PK')).body, + headers: new Headers(), + } + }) as unknown as typeof fetch + + const result = await kickoffApplyModPack( + { getBaseUrl: () => 'https://gt.example', getToken: () => 'gt_x_y' } as never, + 'game-42', + { fetchImpl }, + ) + + expect(result).toEqual({ + ok: false, + error: 'Mod pack exceeds the 4 GiB expanded-size limit; no files were applied', + }) + expect(invoke).not.toHaveBeenCalledWith('apply_staged_mod', expect.anything()) + }) + it('modApplyBlockedReason explains offline and install requirements', () => { expect( modApplyBlockedReason({ diff --git a/clients/desktop/src/apply_mods.ts b/clients/desktop/src/apply_mods.ts index 92b582839..6b342ca98 100644 --- a/clients/desktop/src/apply_mods.ts +++ b/clients/desktop/src/apply_mods.ts @@ -37,7 +37,12 @@ export interface GameModsResponse { active_profile_name: string } -const ZIP_EXTS = new Set(['.zip', '.7z']) +const ZIP_EXTS = new Set(['.zip']) +const UNSUPPORTED_ARCHIVE_EXTS = new Set(['.7z']) +export const MAX_MOD_PACK_DOWNLOAD_BYTES = 1024 * 1024 * 1024 +export const MAX_MOD_PACK_EXPANDED_BYTES = 4 * 1024 * 1024 * 1024 +export const MAX_MOD_PACK_ENTRIES = 50_000 +const MAX_TAURI_CHUNK_BYTES = 1024 * 1024 export function safeModFilename(filename: string): string { const trimmed = (filename || '').trim().replace(/\\/g, '/') @@ -92,6 +97,12 @@ export function isModArchiveFilename(name: string): boolean { return ZIP_EXTS.has(lower.slice(dot)) } +export function isUnsupportedModArchiveFilename(name: string): boolean { + const lower = name.toLowerCase() + const dot = lower.lastIndexOf('.') + return dot >= 0 && UNSUPPORTED_ARCHIVE_EXTS.has(lower.slice(dot)) +} + export function pickModFilename(sourceUrl: string, mod: GameModRow): string { try { const url = new URL(sourceUrl) @@ -174,7 +185,7 @@ export async function fetchGameMods( const fetchImpl = options.fetchImpl ?? fetch const response = await fetchImpl( `${baseUrl.replace(/\/$/, '')}/api/games/${encodeURIComponent(gameUuid)}/mods`, - { headers: { Authorization: formatBearerAuthorization(token) } }, + { headers: { Authorization: formatBearerAuthorization(token) }, redirect: 'error' }, ) if (!response.ok) { throw new Error(`mods fetch ${response.status}`) @@ -285,6 +296,7 @@ export async function fetchModsSummaryGameUuids( const fetchImpl = options.fetchImpl ?? fetch const response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/api/mods/summary`, { headers: { Authorization: formatBearerAuthorization(token) }, + redirect: 'error', }) if (!response.ok) { return { gameUuids: new Set(), trackingEnabled: false } @@ -338,7 +350,8 @@ export async function stageModFromUrl(opts: { gameUuid: string mod: GameModRow fetchImpl?: typeof fetch -}): Promise<{ ok: true; path: string } | { ok: false; error: string }> { + maxBytes?: number +}): Promise<{ ok: true; path: string; bytes: number } | { ok: false; error: string }> { if (!isTauriRuntime()) { return { ok: false, error: 'Companion runtime required to stage mods' } } @@ -346,6 +359,12 @@ export async function stageModFromUrl(opts: { if (!url) { return { ok: false, error: `Mod ${opts.mod.name} has no source URL` } } + if (isUnsupportedModArchiveFilename(opts.mod.name) || isUnsupportedModArchiveFilename(url)) { + return { + ok: false, + error: `${opts.mod.name}: 7z archives are not supported; use a ZIP archive or a direct mod file`, + } + } // The archive is unpacked into a game folder: refuse a source that can be // rewritten in transit (plain http:// to a host outside the local network). const sourceCheck = checkModSourceUrl(url) @@ -367,24 +386,56 @@ export async function stageModFromUrl(opts: { if (!response.ok) { return { ok: false, error: `mod download ${response.status} (${opts.mod.name})` } } - const buffer = await response.arrayBuffer() - const integrity = checkDownloadIntegrity( - buffer, - response.headers?.get?.('content-length') ?? null, - opts.mod.name, + const maxBytes = Math.max( + 0, + Math.min(opts.maxBytes ?? MAX_MOD_PACK_DOWNLOAD_BYTES, MAX_MOD_PACK_DOWNLOAD_BYTES), ) - if (integrity) { - return { ok: false, error: integrity } + const declaredLength = response.headers?.get?.('content-length') ?? null + const declared = declaredLength ? Number(declaredLength) : NaN + if (Number.isFinite(declared) && declared > maxBytes) { + return { + ok: false, + error: `mod download for ${opts.mod.name} exceeds the ${Math.floor(maxBytes / (1024 * 1024))} MiB pack limit`, + } + } + if (!response.body) { + return { ok: false, error: `mod download for ${opts.mod.name} does not support bounded streaming` } } const modsDir = await getModsDir() const stageDir = resolveModStageDir(modsDir, opts.gameUuid, opts.mod.id) const filename = pickModFilename(url, opts.mod) const path = `${stageDir}/${filename}` - await invoke('write_file_bytes', { - path, - bytes: Array.from(new Uint8Array(buffer)), - }) - return { ok: true, path } + await invoke('write_file_bytes', { path, bytes: [] }) + let bytesReceived = 0 + const reader = response.body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (!value?.byteLength) continue + for (let offset = 0; offset < value.byteLength; offset += MAX_TAURI_CHUNK_BYTES) { + const chunk = value.subarray(offset, Math.min(offset + MAX_TAURI_CHUNK_BYTES, value.byteLength)) + bytesReceived += chunk.byteLength + if (bytesReceived > maxBytes) { + throw new Error( + `mod download for ${opts.mod.name} exceeds the ${Math.floor(maxBytes / (1024 * 1024))} MiB pack limit`, + ) + } + await invoke('append_file_bytes', { path, bytes: chunk }) + } + } + if (bytesReceived === 0) { + throw new Error(`mod download for ${opts.mod.name} was empty`) + } + if (Number.isFinite(declared) && declared > 0 && bytesReceived < declared) { + throw new Error(`mod download for ${opts.mod.name} was short (${bytesReceived} of ${declared} bytes)`) + } + return { ok: true, path, bytes: bytesReceived } + } catch (error) { + await reader.cancel().catch(() => undefined) + await invoke('remove_path', { path }).catch(() => undefined) + throw error + } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) } } @@ -393,7 +444,10 @@ export async function stageModFromUrl(opts: { export async function applyStagedModToInstall(opts: { stagedPath: string installRoot: string -}): Promise<{ ok: true; applied: number } | { ok: false; error: string }> { +}): Promise< + | { ok: true; applied: number; expandedBytes: number } + | { ok: false; error: string } +> { if (!isTauriRuntime()) { return { ok: false, error: 'Companion runtime required to apply mods' } } @@ -401,11 +455,11 @@ export async function applyStagedModToInstall(opts: { return { ok: false, error: 'Invalid install root' } } try { - const result = await invoke<{ applied: number }>('apply_staged_mod', { + const result = await invoke<{ applied: number; expandedBytes: number }>('apply_staged_mod', { sourcePath: opts.stagedPath, installRoot: opts.installRoot, }) - return { ok: true, applied: result.applied } + return { ok: true, applied: result.applied, expandedBytes: result.expandedBytes } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) } } @@ -487,13 +541,48 @@ export async function kickoffApplyModPack( return { ok: false, error: 'Game is not installed locally — install first' } } - let appliedMods = 0 - let filesApplied = 0 + const stagedMods: Array<{ path: string }> = [] + let downloadedBytes = 0 for (const mod of enabled) { - const staged = await stageModFromUrl({ gameUuid, mod, fetchImpl: options.fetchImpl }) + const staged = await stageModFromUrl({ + gameUuid, + mod, + fetchImpl: options.fetchImpl, + maxBytes: MAX_MOD_PACK_DOWNLOAD_BYTES - downloadedBytes, + }) if (!staged.ok) { return { ok: false, error: staged.error } } + downloadedBytes += staged.bytes + stagedMods.push({ path: staged.path }) + } + + let expandedBytes = 0 + let entryCount = 0 + for (const staged of stagedMods) { + const stats = await invoke<{ expandedBytes: number; entryCount: number }>( + 'inspect_staged_mod', + { sourcePath: staged.path }, + ).catch((err: unknown) => ({ error: err instanceof Error ? err.message : String(err) })) + if ('error' in stats) { + return { ok: false, error: stats.error } + } + expandedBytes += stats.expandedBytes + entryCount += stats.entryCount + if (expandedBytes > MAX_MOD_PACK_EXPANDED_BYTES) { + return { ok: false, error: 'Mod pack exceeds the 4 GiB expanded-size limit; no files were applied' } + } + if (entryCount > MAX_MOD_PACK_ENTRIES) { + return { + ok: false, + error: `Mod pack exceeds the ${MAX_MOD_PACK_ENTRIES}-entry limit; no files were applied`, + } + } + } + + let appliedMods = 0 + let filesApplied = 0 + for (const staged of stagedMods) { const applied = await applyStagedModToInstall({ stagedPath: staged.path, installRoot: install.extractPath, diff --git a/clients/desktop/src/apply_patch.test.ts b/clients/desktop/src/apply_patch.test.ts index 704caa1b9..c42ad57ca 100644 --- a/clients/desktop/src/apply_patch.test.ts +++ b/clients/desktop/src/apply_patch.test.ts @@ -60,10 +60,20 @@ describe('apply_patch helpers', () => { } return undefined }) - const fetchImpl = vi.fn(async () => ({ - ok: true, - arrayBuffer: async () => new TextEncoder().encode('BPS1').buffer, - })) as unknown as typeof fetch + const fetchImpl = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + url: 'https://gt.example/api/downloads/games/game-42', + json: async () => ({ ok: true, stream_url: '/download_zip/80' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + url: 'https://gt.example/download_zip/80', + arrayBuffer: async () => new TextEncoder().encode('BPS1').buffer, + }) as unknown as typeof fetch const result = await stagePatchFile({ gameUuid: 'game-42', @@ -74,9 +84,20 @@ describe('apply_patch helpers', () => { }) expect(result).toEqual({ ok: true, path: '/appdata/patches/game-42/en.bps' }) - expect(fetchImpl).toHaveBeenCalledWith( - 'https://gt.example/download_other/extra/game-42/patch-9', - expect.objectContaining({ credentials: 'include' }), + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + 'https://gt.example/api/downloads/games/game-42', + expect.objectContaining({ + method: 'POST', + credentials: 'omit', + redirect: 'error', + body: JSON.stringify({ kind: 'extra', version_uuid: 'patch-9' }), + }), + ) + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, + 'https://gt.example/download_zip/80', + expect.objectContaining({ credentials: 'omit' }), ) expect(invoke).toHaveBeenCalledWith('write_file_bytes', { path: '/appdata/patches/game-42/en.bps', diff --git a/clients/desktop/src/apply_patch.ts b/clients/desktop/src/apply_patch.ts index 33a038915..328bb6b46 100644 --- a/clients/desktop/src/apply_patch.ts +++ b/clients/desktop/src/apply_patch.ts @@ -7,6 +7,7 @@ import { invoke } from '@tauri-apps/api/core' import { isTauriRuntime } from './config-store.js' import { joinUrl } from './paths.js' +import { checkDownloadRedirectUrl } from './transport-policy.js' const PATCH_EXTS = new Set(['.ips', '.bps', '.ups']) @@ -76,14 +77,45 @@ export async function stagePatchFile(opts: { return { ok: false, error: 'Companion runtime required to stage patches' } } const base = (opts.apiBase || '').replace(/\/$/, '') - const url = `${base}/download_other/extra/${encodeURIComponent(opts.gameUuid)}/${encodeURIComponent(opts.patchUuid)}` const fetchFn = opts.fetchImpl || fetch try { const headers: Record = {} if (opts.authHeader) { headers.Authorization = opts.authHeader } - const response = await fetchFn(url, { credentials: 'include', headers }) + const initiation = await fetchFn( + `${base}/api/downloads/games/${encodeURIComponent(opts.gameUuid)}`, + { + method: 'POST', + credentials: 'omit', + redirect: 'error', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'extra', version_uuid: opts.patchUuid }), + }, + ) + if (initiation.url) { + const check = checkDownloadRedirectUrl(initiation.url) + if (!check.ok) return { ok: false, error: check.message } + } + if (!initiation.ok) { + return { ok: false, error: `patch request ${initiation.status}` } + } + const initiated = await initiation.json() + if (initiated?.ok !== true) { + return { ok: false, error: initiated?.error || 'patch request was rejected' } + } + const streamPath = initiated?.stream_url + if (typeof streamPath !== 'string' || !streamPath.startsWith('/')) { + return { ok: false, error: 'patch request returned an invalid stream URL' } + } + const response = await fetchFn(joinUrl(base, streamPath), { + credentials: 'omit', + headers, + }) + if (response.url) { + const check = checkDownloadRedirectUrl(response.url) + if (!check.ok) return { ok: false, error: check.message } + } if (!response.ok) { return { ok: false, error: `patch download ${response.status}` } } @@ -129,8 +161,3 @@ export async function kickoffApplyPatch(opts: { return { ok: false, error: err instanceof Error ? err.message : String(err) } } } - -/** Resolve auth-aware absolute download URL (tests / callers). */ -export function patchDownloadUrl(baseUrl: string, gameUuid: string, patchUuid: string): string { - return joinUrl(baseUrl, `/download_other/extra/${gameUuid}/${patchUuid}`) -} diff --git a/clients/desktop/src/assists.test.ts b/clients/desktop/src/assists.test.ts new file mode 100644 index 000000000..ff6eaab1d --- /dev/null +++ b/clients/desktop/src/assists.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' + +import { fetchAssistPack } from './assists.js' + +describe('fetchAssistPack', () => { + it('refuses redirects for bearer-authenticated API reads', async () => { + const fetchImpl = vi.fn(async () => ({ + ok: true, + json: async () => ({ enabled: true, pack: { game_uuid: 'g1', title: 'Assist', policy: 'safe', toggles: [] } }), + })) as unknown as typeof fetch + + const result = await fetchAssistPack( + { getBaseUrl: () => 'https://server.example', getToken: () => 'gt_secret' } as never, + 'g1', + { fetchImpl }, + ) + + expect(result?.title).toBe('Assist') + expect(fetchImpl).toHaveBeenCalledWith( + 'https://server.example/api/games/g1/assists', + expect.objectContaining({ redirect: 'error' }), + ) + }) +}) diff --git a/clients/desktop/src/assists.ts b/clients/desktop/src/assists.ts index c8d2991c8..17060cf7d 100644 --- a/clients/desktop/src/assists.ts +++ b/clients/desktop/src/assists.ts @@ -36,6 +36,7 @@ export async function fetchAssistPack( `${baseUrl.replace(/\/$/, '')}/api/games/${encodeURIComponent(gameUuid)}/assists`, { headers: { Authorization: formatBearerAuthorization(token) }, + redirect: 'error', }, ) if (!response.ok) { diff --git a/clients/desktop/src/download-picker.test.ts b/clients/desktop/src/download-picker.test.ts index 98f85d5a8..d4e2d208a 100644 --- a/clients/desktop/src/download-picker.test.ts +++ b/clients/desktop/src/download-picker.test.ts @@ -12,6 +12,38 @@ const api = () => ({ downloads: { listGameVersions: vi.fn().mockResolvedValue({ afterEach(() => document.body.replaceChildren()) describe('download version choice', () => { + it('names the version group, contains Tab focus, and restores the launcher focus', async () => { + const launcher = document.createElement('button') + launcher.textContent = 'Install game' + document.body.append(launcher) + launcher.focus() + + const pending = pickDownloadVersion(api() as unknown as OneirodexClient, 'game-1') + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull()) + + const dialog = document.querySelector('[role="dialog"]')! + const group = dialog.querySelector('[role="radiogroup"]')! + const options = dialog.querySelectorAll('.od-version-option') + const cancel = dialog.querySelector('.od-version-cancel')! + const confirm = dialog.querySelector('.od-version-confirm')! + expect(group.getAttribute('aria-label')).toBe('Available download versions') + expect(document.activeElement).toBe(options[0]) + + confirm.focus() + confirm.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })) + expect(document.activeElement).toBe(options[0]) + + options[0]!.focus() + options[0]!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }), + ) + expect(document.activeElement).toBe(confirm) + + cancel.click() + await expect(pending).resolves.toBeNull() + expect(document.activeElement).toBe(launcher) + }) + it.each(['Cancel', 'Escape', 'backdrop'])('preserves %s as cancellation', async (action) => { const pending = pickDownloadVersion(api() as unknown as OneirodexClient, 'game-1') await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull()) diff --git a/clients/desktop/src/download.ts b/clients/desktop/src/download.ts index 981758104..9ac31b120 100644 --- a/clients/desktop/src/download.ts +++ b/clients/desktop/src/download.ts @@ -329,8 +329,8 @@ export async function pickDownloadVersion( * In-DOM version picker. Replaces `window.prompt()`: a synchronous prompt blocks * the Tauri webview event loop, cannot be styled or keyboard-driven, and reads * as a browser artefact in a native window. This renders a small modal overlay - * in the same hand-built style as the rest of `app.ts` — a radiogroup of - * versions, arrow-key navigation, Enter to confirm, Escape / backdrop to cancel. + * in the same hand-built style as the rest of `app.ts` — a named radiogroup of + * versions, arrow-key navigation, Tab containment, Escape / backdrop to cancel. * * Resolves with the chosen version, or `null` when dismissed or when there is no * DOM to render into (tests, non-webview runs). Callers must stop the download @@ -345,6 +345,9 @@ export function promptForDownloadVersion( } return new Promise((resolve) => { + const returnFocus = document.activeElement instanceof HTMLElement + ? document.activeElement + : null const defaultIndex = Math.max( versions.findIndex((version) => version.is_default), 0, @@ -368,6 +371,7 @@ export function promptForDownloadVersion( const group = document.createElement('div') group.className = 'od-version-list' group.setAttribute('role', 'radiogroup') + group.setAttribute('aria-label', 'Available download versions') const options: HTMLButtonElement[] = versions.map((version, index) => { const option = document.createElement('button') @@ -392,12 +396,12 @@ export function promptForDownloadVersion( actions.className = 'od-version-actions' const cancelButton = document.createElement('button') cancelButton.type = 'button' - cancelButton.className = 'od-version-cancel' + cancelButton.className = 'od-version-cancel secondary' cancelButton.textContent = 'Cancel' cancelButton.addEventListener('click', () => finish(null)) const confirmButton = document.createElement('button') confirmButton.type = 'button' - confirmButton.className = 'od-version-confirm' + confirmButton.className = 'od-version-confirm primary' confirmButton.textContent = 'Download' confirmButton.addEventListener('click', () => finish(versions[selectedIndex] ?? null)) actions.append(cancelButton, confirmButton) @@ -414,10 +418,31 @@ export function promptForDownloadVersion( if (event.key === 'Escape') { event.preventDefault() finish(null) - } else if (event.key === 'Enter') { - event.preventDefault() - finish(versions[selectedIndex] ?? null) - } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + } else if (event.key === 'Tab') { + const focusable = Array.from( + overlay.querySelectorAll( + 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', + ), + ) + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (!first || !last) { + event.preventDefault() + return + } + const active = document.activeElement + if (event.shiftKey && (active === first || !overlay.contains(active))) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && (active === last || !overlay.contains(active))) { + event.preventDefault() + first.focus() + } + } else if ( + (event.key === 'ArrowDown' || event.key === 'ArrowUp') && + event.target instanceof HTMLElement && + event.target.getAttribute('role') === 'radio' + ) { event.preventDefault() const delta = event.key === 'ArrowDown' ? 1 : -1 selectedIndex = (selectedIndex + delta + versions.length) % versions.length @@ -442,6 +467,9 @@ export function promptForDownloadVersion( settled = true document.removeEventListener('keydown', onKeyDown, true) overlay.remove() + if (returnFocus?.isConnected) { + returnFocus.focus() + } resolve(result) } diff --git a/clients/desktop/src/lifecycle-store.test.ts b/clients/desktop/src/lifecycle-store.test.ts index ef8ca8c1e..55aef1fe6 100644 --- a/clients/desktop/src/lifecycle-store.test.ts +++ b/clients/desktop/src/lifecycle-store.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createPersistedLifecycleRegistry } from './lifecycle-store.js' +import { + createPersistedLifecycleRegistry, + syncLifecycleRegistryToServer, +} from './lifecycle-store.js' import { createLifecycleRegistry } from './lifecycle.js' vi.mock('@tauri-apps/api/core', () => ({ @@ -33,4 +36,18 @@ describe('lifecycle persistence helpers', () => { expect(registry.get('game-9')).toBe('update_available') }) + + it('refuses redirects when syncing bearer-authenticated lifecycle state', async () => { + const fetchImpl = vi.fn(async () => ({ ok: true, status: 200 })) as unknown as typeof fetch + await syncLifecycleRegistryToServer( + { getBaseUrl: () => 'https://server.example', getToken: () => 'gt_secret' } as never, + [], + fetchImpl, + ) + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://server.example/api/client/lifecycle', + expect.objectContaining({ redirect: 'error' }), + ) + }) }) diff --git a/clients/desktop/src/lifecycle-store.ts b/clients/desktop/src/lifecycle-store.ts index 5a2ddb1f0..90611d065 100644 --- a/clients/desktop/src/lifecycle-store.ts +++ b/clients/desktop/src/lifecycle-store.ts @@ -77,6 +77,7 @@ export async function syncLifecycleRegistryToServer( try { const response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/api/client/lifecycle`, { method: 'POST', + redirect: 'error', headers: { Authorization: formatBearerAuthorization(token), 'Content-Type': 'application/json', @@ -106,6 +107,7 @@ export async function pullLifecycleRegistryFromServer( try { const response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/api/client/lifecycle`, { method: 'GET', + redirect: 'error', headers: { Authorization: formatBearerAuthorization(token), }, diff --git a/clients/desktop/src/styles.css b/clients/desktop/src/styles.css index 4a4db4171..895dcacce 100644 --- a/clients/desktop/src/styles.css +++ b/clients/desktop/src/styles.css @@ -1,9 +1,11 @@ +@import '../../../oneirodex/setup/default_theme/css/od-tokens.css'; + :root { color-scheme: dark; font-family: 'Segoe UI', system-ui, sans-serif; line-height: 1.5; - background: #0f1419; - color: #e7ecf1; + background: var(--od-bg); + color: var(--od-text); } * { @@ -25,15 +27,15 @@ header h1 { } .muted { - color: #9aa7b5; + color: var(--od-text-muted); } .panel { margin-top: 1.5rem; padding: 1rem 1.25rem; - border: 1px solid #243041; + border: 1px solid var(--od-border); border-radius: 12px; - background: #151b24; + background: var(--od-surface); } .connect-form { @@ -51,16 +53,16 @@ label { input { padding: 0.55rem 0.65rem; border-radius: 8px; - border: 1px solid #314055; - background: #0f1419; + border: 1px solid var(--od-border); + background: var(--od-bg); color: inherit; } textarea { padding: 0.55rem 0.65rem; border-radius: 8px; - border: 1px solid #314055; - background: #0f1419; + border: 1px solid var(--od-border); + background: var(--od-bg); color: inherit; font-family: inherit; resize: vertical; @@ -69,25 +71,45 @@ textarea { button, .action-btn { cursor: pointer; - border: 0; + border: 1px solid transparent; border-radius: 8px; padding: 0.55rem 0.9rem; - background: #3b82f6; - color: white; + background: var(--od-accent); + color: var(--od-accent-contrast); font-weight: 600; } +button.primary { + background: var(--od-accent); + color: var(--od-accent-contrast); +} + +button.secondary { + border-color: var(--od-border); + background: var(--od-surface-2); + color: var(--od-text); +} + +button:focus-visible, +.action-btn:focus-visible, +input:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--od-focus-ring); + outline-offset: 2px; +} + button:disabled { opacity: 0.6; cursor: not-allowed; } .action-btn.play-btn { - background: #1f7a45; + background: #176b42; + color: #fff; } .action-btn.play-btn:hover:not(:disabled) { - background: #269653; + background: #1b784b; } .status { @@ -96,20 +118,20 @@ button:disabled { } .status[data-tone='error'] { - color: #f87171; + color: var(--od-danger); } .status[data-tone='success'] { - color: #4ade80; + color: var(--od-success); } .offline-banner { margin: 0 0 0.75rem; padding: 0.65rem 0.75rem; border-radius: 8px; - border: 1px solid #7f1d1d; + border: 1px solid var(--od-danger); background: #1f1215; - color: #fca5a5; + color: var(--od-danger); font-size: 0.9rem; } @@ -122,8 +144,8 @@ button:disabled { .game-card { padding: 0.85rem; border-radius: 10px; - border: 1px solid #243041; - background: #10161f; + border: 1px solid var(--od-border); + background: var(--od-surface-2); } .game-card h3 { @@ -134,7 +156,7 @@ button:disabled { .uuid { margin: 0; font-size: 0.75rem; - color: #9aa7b5; + color: var(--od-text-muted); word-break: break-all; } @@ -145,7 +167,7 @@ button:disabled { .activity { margin: 0.35rem 0; font-size: 0.8rem; - color: #93c5fd; + color: var(--od-info); } .actions { @@ -157,7 +179,8 @@ button:disabled { .action-btn { font-size: 0.75rem; padding: 0.35rem 0.55rem; - background: #1f2937; + background: var(--od-surface-2); + color: var(--od-text); } .lifecycle-list { @@ -191,9 +214,9 @@ button:disabled { .od-version-panel { width: min(420px, 100%); padding: 1.1rem 1.25rem 1.25rem; - border: 1px solid #243041; + border: 1px solid var(--od-border); border-radius: 12px; - background: #151b24; + background: var(--od-surface); box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); } @@ -219,18 +242,18 @@ button:disabled { width: 100%; text-align: left; font-weight: 500; - background: #10161f; - border: 1px solid #243041; + background: var(--od-surface-2); + border: 1px solid var(--od-border); color: inherit; } .od-version-option:hover:not(:disabled) { - border-color: #3b82f6; + border-color: var(--od-accent); } .od-version-option.is-selected { - border-color: #3b82f6; - background: #16233a; + border-color: var(--od-accent); + background: var(--od-surface-2); } .od-version-actions { @@ -241,5 +264,5 @@ button:disabled { } .od-version-cancel { - background: #1f2937; + background: var(--od-surface-2); } diff --git a/clients/desktop/src/thin-app.ts b/clients/desktop/src/thin-app.ts index fc18a3de3..03f65c1ef 100644 --- a/clients/desktop/src/thin-app.ts +++ b/clients/desktop/src/thin-app.ts @@ -130,39 +130,43 @@ async function openLibraryWindow(baseUrl: string): Promise<'opened' | 'focused' */ export async function mountThinApp(root: HTMLElement): Promise { root.innerHTML = ` -
    -

    Oneirodex Thin

    -

    Browse, social & browser play — no local install pipeline

    -
    -
    - -

    Sign in with your site account inside the library / friends windows. API token optional for presence only.

    - -
    - - - - -
    -

    Enter your Oneirodex URL to begin.

    -
    -
    -

    What this client does

    -
      -
    • Opens your household library in a dedicated window
    • -
    • Friends popup (bottom-right, always-on-top)
    • -
    • Uses thin device capabilities (no download / install / native play)
    • -
    • With a token, reports presence as a thin seat so it shows up in Ops
    • -
    -

    Need Install / Update / Play for PC titles? Use the full Oneirodex desktop companion.

    -
    +
    +
    +

    Oneirodex Thin

    +

    Browse, social & browser play — no local install pipeline

    +
    +
    + +

    Sign in with your site account inside the library / friends windows. API token optional for presence only.

    + +
    + + + + +
    +

    Enter your Oneirodex URL to begin.

    +
    +
    +

    What this client does

    +
      +
    • Opens your household library in a dedicated window
    • +
    • Friends popup (bottom-right, always-on-top)
    • +
    • Uses thin device capabilities (no download / install / native play)
    • +
    • With a token, reports presence as a thin seat so it shows up in Ops
    • +
    +

    Need Install / Update / Play for PC titles? Use the full Oneirodex desktop companion.

    +
    +
    ` const baseUrlEl = root.querySelector('#baseUrl')! const tokenEl = root.querySelector('#token')! const statusEl = root.querySelector('#status')! const setStatus = (message: string, tone: 'info' | 'error' | 'success' = 'info') => { - statusEl.textContent = message + if (statusEl.textContent !== message) { + statusEl.textContent = message + } statusEl.dataset.tone = tone } diff --git a/config.py b/config.py index 777a737ea..6b20b9c55 100644 --- a/config.py +++ b/config.py @@ -185,9 +185,12 @@ class Config(object): DEV_MODE = _env_bool('DEV_MODE', False) # Reverse proxy / HTTPS termination — number of trusted proxy hops (0 = disabled). - # Set to 1 when Oneirodex sits behind a single reverse proxy (nginx, Caddy, Traefik) - # so X-Forwarded-Proto/Host are honored for OIDC redirects and external URLs. + # Set to the forwarded hop count when behind a proxy; forwarded values are + # honored only when that peer also matches TRUSTED_PROXY_CIDRS below. TRUSTED_PROXIES = int(os.getenv('TRUSTED_PROXIES', '0') or '0') + # The hop count does not authenticate a proxy; only matching peer addresses + # in this comma-separated IP/CIDR list may supply forwarded headers. + TRUSTED_PROXY_CIDRS = os.getenv('TRUSTED_PROXY_CIDRS', '') # Hosts allowed to become the origin of an emailed link (password reset, # invite). Empty = trust private/LAN hosts only, which is what a household @@ -417,11 +420,6 @@ class Config(object): SABNZBD_URL = os.getenv('SABNZBD_URL', '') SABNZBD_API_KEY = os.getenv('SABNZBD_API_KEY', '') - # Flask-Babel / i18n - BABEL_DEFAULT_LOCALE = os.getenv('BABEL_DEFAULT_LOCALE', 'en') - BABEL_SUPPORTED_LOCALES = ['en', 'es'] - - class ProdConfig(Config): """Explicit production profile — what create_app() selects outside pytest. diff --git a/docker-compose.single.yml b/docker-compose.single.yml index 0017a4f65..a91f4f19f 100644 --- a/docker-compose.single.yml +++ b/docker-compose.single.yml @@ -14,7 +14,7 @@ services: args: ONEIRODEX_BUILD_SHA: ${ONEIRODEX_BUILD_SHA:-} ONEIRODEX_BUILT_AT: ${ONEIRODEX_BUILT_AT:-} - image: ${APP_IMAGE:-oneirodex:1.1.1} + image: ${APP_IMAGE:-oneirodex:1.1.9} container_name: ${APP_CONTAINER_NAME:-oneirodex-app} restart: unless-stopped ports: diff --git a/docker-compose.yml b/docker-compose.yml index f85650971..87f34d53f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,10 +29,10 @@ # P3b (ADR 0003): new installs default to oneirodex-* names. Live Unraid stacks # that still run oneirodex-app / oneirodex-db should pin those names in .env # until the scan FIFO is idle, then drop the pins so Compose recreates: -# APP_IMAGE=oneirodex:1.1.8 +# APP_IMAGE=oneirodex:1.1.9 # APP_CONTAINER_NAME=oneirodex-app # DB_CONTAINER_NAME=oneirodex-db -# Preferred Hub image: cephyrixzyth/oneirodex:1.1.8. Postgres *database* name stays +# Preferred Hub image: cephyrixzyth/oneirodex:1.1.9. Postgres *database* name stays # POSTGRES_DB (default oneirodex) so existing volumes keep their catalog. # Sidecar profile container names (livekit / clamav / …) are unchanged. # --------------------------------------------------------------------------- @@ -48,7 +48,7 @@ services: args: ONEIRODEX_BUILD_SHA: ${ONEIRODEX_BUILD_SHA:-} ONEIRODEX_BUILT_AT: ${ONEIRODEX_BUILT_AT:-} - image: ${APP_IMAGE:-oneirodex:1.1.8} + image: ${APP_IMAGE:-oneirodex:1.1.9} container_name: ${APP_CONTAINER_NAME:-oneirodex-app} restart: unless-stopped ports: @@ -118,6 +118,7 @@ services: - CHAT_ATTACHMENT_STORAGE_MAX_BYTES=${CHAT_ATTACHMENT_STORAGE_MAX_BYTES:-1073741824} - CHAT_ATTACHMENT_STORAGE_MAX_FILES=${CHAT_ATTACHMENT_STORAGE_MAX_FILES:-10000} - TRUSTED_PROXIES=${TRUSTED_PROXIES:-0} + - TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-} - ENABLE_LIVEKIT=${ENABLE_LIVEKIT:-true} - LIVEKIT_URL=${LIVEKIT_URL:-} - LIVEKIT_API_KEY=${LIVEKIT_API_KEY:-} @@ -200,6 +201,7 @@ services: # (docs/runbooks/docker-compose-deploy.md § Postgres password). - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB=${POSTGRES_DB:-oneirodex} + - POSTGRES_HOST_BIND=${POSTGRES_HOST_BIND:-127.0.0.1} - PGDATA=/var/lib/postgresql/data/pgdata # Force a Compose-friendly HBA so Docker-bridge IPs can connect without SSL. # (Stale volume pg_hba with only hostssl → "no encryption" / no pg_hba entry.) @@ -212,9 +214,12 @@ services: - listen_addresses=* - -c - hba_file=/etc/oneirodex/pg_hba.conf + entrypoint: ["/bin/sh", "/usr/local/bin/oneirodex-postgres-entrypoint.sh"] volumes: - db_data:/var/lib/postgresql/data/pgdata - ./docker/postgres/pg_hba.conf:/etc/oneirodex/pg_hba.conf:ro + - ./docker/postgres/guard-entrypoint.sh:/usr/local/bin/oneirodex-postgres-entrypoint.sh:ro + - ./docker/postgres/guard-policy.sh:/usr/local/bin/oneirodex-postgres-guard.sh:ro ports: # Host access for local pytest / native app against Compose Postgres. # Loopback only by default: a Postgres superuser login must not be offered diff --git a/docker/postgres/guard-entrypoint.sh b/docker/postgres/guard-entrypoint.sh new file mode 100644 index 000000000..b1bd2db1f --- /dev/null +++ b/docker/postgres/guard-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +. /usr/local/bin/oneirodex-postgres-guard.sh +oneirodex_guard_postgres_publication + +case "${POSTGRES_HOST_BIND:-127.0.0.1}" in + 127.*|localhost|::1|'[::1]') ;; + *) oneirodex_verify_persisted_postgres_password ;; +esac + +exec /usr/local/bin/docker-entrypoint.sh "$@" diff --git a/docker/postgres/guard-policy.sh b/docker/postgres/guard-policy.sh new file mode 100644 index 000000000..0c0f2204e --- /dev/null +++ b/docker/postgres/guard-policy.sh @@ -0,0 +1,92 @@ +# Shared policy for the Compose PostgreSQL entrypoint. Keep this POSIX-shell +# compatible so the official postgres image can run it before initialization. +oneirodex_guard_postgres_publication() { + bind_address=${POSTGRES_HOST_BIND:-127.0.0.1} + password=${POSTGRES_PASSWORD:-} + + case "$bind_address" in + 127.*|localhost|::1|'[::1]') return 0 ;; + esac + + case "$password" in + ''|postgres|CHANGE_ME*) + echo "PostgreSQL refuses non-loopback publication with an unset, default, or template password." >&2 + echo "Rotate the database password or restore POSTGRES_HOST_BIND=127.0.0.1." >&2 + return 1 + ;; + esac + + return 0 +} + +# Existing data volumes ignore POSTGRES_PASSWORD during initialization. Before +# publishing one beyond loopback, prove that the configured credential works +# against the stored database. The probe listener is container-loopback only +# and uses a port that Compose does not publish. +oneirodex_verify_persisted_postgres_password() { + pgdata=${PGDATA:-/var/lib/postgresql/data} + if [ ! -s "$pgdata/PG_VERSION" ]; then + # A fresh volume will be initialized from POSTGRES_PASSWORD by the + # official entrypoint, so the configured credential is authoritative. + return 0 + fi + + probe_port=55432 + probe_options="-c listen_addresses=127.0.0.1 -c port=$probe_port -c hba_file=/etc/oneirodex/pg_hba.conf -c unix_socket_directories=/var/run/postgresql" + if ! gosu postgres pg_ctl -D "$pgdata" -o "$probe_options" -w start >/dev/null 2>&1; then + echo "PostgreSQL refuses non-loopback publication: the existing database could not be checked safely." >&2 + return 1 + fi + probe_started=true + oneirodex_stop_probe() { + if [ "$probe_started" = true ]; then + gosu postgres pg_ctl -D "$pgdata" -m fast -w stop >/dev/null 2>&1 || true + probe_started=false + fi + } + trap oneirodex_stop_probe EXIT + trap 'exit 1' HUP INT TERM + + superuser_state=$(psql \ + --no-password --no-psqlrc --set=ON_ERROR_STOP=1 \ + --host=/var/run/postgresql --port="$probe_port" \ + --username="${POSTGRES_USER:-postgres}" \ + --dbname="${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" \ + --tuples-only --no-align \ + --command='SELECT count(*) = 1 AND COALESCE(bool_or(rolname = current_user), false) FROM pg_roles WHERE rolsuper AND rolcanlogin' \ + 2>/dev/null || true) + + role_ok=false + if [ "$superuser_state" = t ]; then + role_ok=true + fi + + probe_ok=false + if [ "$role_ok" = true ] && PGPASSWORD="$POSTGRES_PASSWORD" psql \ + --no-password --no-psqlrc --set=ON_ERROR_STOP=1 \ + --host=127.0.0.1 --port="$probe_port" \ + --username="${POSTGRES_USER:-postgres}" \ + --dbname="${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" \ + --tuples-only --no-align --command='SELECT 1' >/dev/null 2>&1; then + probe_ok=true + fi + + if ! gosu postgres pg_ctl -D "$pgdata" -m fast -w stop >/dev/null 2>&1; then + echo "PostgreSQL refuses non-loopback publication: the private credential-check server did not stop cleanly." >&2 + return 1 + fi + probe_started=false + trap - EXIT HUP INT TERM + + if [ "$role_ok" != true ]; then + echo "PostgreSQL refuses non-loopback publication: the configured account is not the sole login-capable superuser." >&2 + echo "Keep POSTGRES_HOST_BIND on loopback or leave one configured login-capable superuser." >&2 + return 1 + fi + + if [ "$probe_ok" != true ]; then + echo "PostgreSQL refuses non-loopback publication: POSTGRES_PASSWORD does not authenticate to the existing database." >&2 + echo "Keep POSTGRES_HOST_BIND on loopback, rotate the password in the database, then retry." >&2 + return 1 + fi +} diff --git a/docs/README.md b/docs/README.md index f277ab8b7..3dc198d30 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Oneirodex documentation -**Product version:** 1.1.8 — see root [CHANGELOG.md](../CHANGELOG.md) and [VERSION](../VERSION). +**Product version:** 1.1.9 — see root [CHANGELOG.md](../CHANGELOG.md) and [VERSION](../VERSION). Hub for product, ops, and developer docs. Public name **Oneirodex**. Runtime env is `ONEIRODEX_*` only ([ADR 0003](adr/0003-product-name-oneirodex.md)). Package `oneirodex/`, Compose defaults `oneirodex-*`, preferred Hub image `cephyrixzyth/oneirodex` once published. @@ -23,6 +23,7 @@ Root [README.md](../README.md) includes badges, feature tour, screenshots (`docs | Households / VR runtime | [runbooks/vr-byo-runtime.md](runbooks/vr-byo-runtime.md) — OpenXR runtime · OpenVR adapter · streamers · the wired USB-NCM link (0.7 ms / ~756 Mbps on a Quest 2) · tweaks as documentation, never distributed | | Operators / achievements | [runbooks/retroachievements.md](runbooks/retroachievements.md) — match ROMs to community achievement sets (opt-in key); nothing played in the browser unlocks | | Maintainers / theme art | [dev/theme-art-direction.md](dev/theme-art-direction.md) — what a theme is beyond a hue: room art, the accent sentinel, and how a generated backdrop lands | +| Maintainers / UI foundations | [dev/ui-style-guide.md](dev/ui-style-guide.md) — working rules for shared web UI, icons, filters, forms, tables, actions, and responsive review | | Operators / WebRetro cores | [runbooks/webretro-cores.md](runbooks/webretro-cores.md) · [admin/webretro-core-clauses.md](admin/webretro-core-clauses.md) (non-commercial clauses — not counsel) | | Operators / emulator BIOS | [runbooks/emulator-bios.md](runbooks/emulator-bios.md) — operator-supplied firmware; Admin scan/install, filenames only | | Operators / ROM reference sets | [runbooks/reference-sets.md](runbooks/reference-sets.md) | @@ -37,8 +38,9 @@ Root [README.md](../README.md) includes badges, feature tour, screenshots (`docs | Maintainers / database evaluation | [adr/0012-evaluate-mariadb-before-engine-change.md](adr/0012-evaluate-mariadb-before-engine-change.md) — cost and compatibility gates; proposal only | | CI gates (maintainers) | [dev/ci-gates.md](dev/ci-gates.md) — current marker selection, explicit exclusions and coverage gate. Replaces `ci-wishlist.md` | | Support triage (maintainers) | [dev/ui-debt-log.md](dev/ui-debt-log.md) · [dev/api-envelope-keeps.md](dev/api-envelope-keeps.md) | +| UI system (maintainers) | [dev/ui-style-guide.md](dev/ui-style-guide.md) — tokens, controls, icons, theme layers, contribution workflow, and private shared-package contract; the local catalog is `frontend/ui-catalog` (commands and limits in the guide) | | Test harness (maintainers) | [dev/test-harness-2026-09-10.md](dev/test-harness-2026-09-10.md) — per-test SAVEPOINT isolation · savepoint-restart listener · `database` / `slow` markers. Older harness notes and the 2026-09-16 failure list are in [dev/archive/](dev/archive/README.md) | -| Admin Jinja → React (maintainers) | [dev/admin-jinja-inventory.md](dev/admin-jinja-inventory.md) — which admin pages still render server-side, their handlers and APIs, and the port order (H-D.5) | +| Template retirement and runtime migration (maintainers) | [dev/admin-jinja-inventory.md](dev/admin-jinja-inventory.md) — template/render census and runtime dependency boundary | | Writing an API route (maintainers) | [dev/pydantic-adoption.md](dev/pydantic-adoption.md) — `@validate_body` request models · which routes are migrated · adoption backlog | | Maintainers / browser engines | [dev/browser-play-engines.md](dev/browser-play-engines.md) — WebRetro · Nostalgist/koin · EmulatorJS · webЯcade sidecar · [runbooks/emulatorjs.md](runbooks/emulatorjs.md) (bundled engine B) | | OIDC / Authentik SSO | [runbooks/oidc-sso.md](runbooks/oidc-sso.md), [runbooks/oidc-authentik-unraid.md](runbooks/oidc-authentik-unraid.md) | @@ -89,7 +91,7 @@ Docker and cloud demo operations: [Docker Compose deploy](runbooks/docker-compos |---|---| | Product (shipped today) | Oneirodex (public string) | | Ops / code identifiers | Compose defaults `oneirodex-*`; npm `@oneirodex/api-client`; env `ONEIRODEX_*` only — [ADR 0003](adr/0003-product-name-oneirodex.md) | -| Version | 1.1.8 | +| Version | 1.1.9 | | GitHub | cephyrixzyth/oneirodex | | Containers | oneirodex-app (single-container or app role) · oneirodex-db (optional separate Postgres) | | Optional voice | oneirodex-livekit (`--profile livekit`) | diff --git a/docs/admin/discover-sections.md b/docs/admin/discover-sections.md index bcff61c5d..b1d53fa73 100644 --- a/docs/admin/discover-sections.md +++ b/docs/admin/discover-sections.md @@ -6,6 +6,12 @@ Admin → **Discovery Sections Management** (`/admin/discovery_sections`) controls the shelves members see on `/discover`. +## Optional native admin page + +This page uses the packaged admin React shell, shared forms, and named Edit/Delete controls through FastAPI. Move up/down buttons support keyboard ordering. Manual shelves support local game search, ordered selection and deduplicated UUID entry (maximum 60 games); filtered shelves retain library, platform and genre selection. Built-in shelves cannot be deleted or have their selection edited. Discovery-section reads and mutations also dispatch through FastAPI while preserving the existing admin session and CSRF contract. The application still has legacy Flask services and extensions during the runtime migration. + +Schedule controls save layout and UTC start/end dates together; an invalid window preserves the saved schedule. Pin rank has a separate Save action. Visibility and ordering refresh from persisted state. Delete requires confirmation; failed saves retain the draft and failed loading offers Retry. Flask retains the route and guarded API boundary. + ## Built-in vs custom - **Built-in shelves** (Continue Playing, New Arrivals, Most Favorited, libraries, …) can be **reordered** and **hidden** (visibility toggle) but not deleted. diff --git a/docs/admin/libraries-and-scans.md b/docs/admin/libraries-and-scans.md index 25fbb8fa4..9a0516214 100644 --- a/docs/admin/libraries-and-scans.md +++ b/docs/admin/libraries-and-scans.md @@ -4,11 +4,17 @@ > 🎬 Watch (narrated): [libraries & scans](../media/video/howto/howto-admin-libraries.mp4) · [ops health](../media/video/howto/howto-admin-ops.mp4) — [all how-to videos, with transcripts](../media/video/howto/README.md) -Admin surfaces today are **Jinja** under `base_admin` (top bar), hosted inside the admin SPA shell (LHN + thin THN). +The standard server-rendered admin surfaces remain available as a compatibility fallback. With the opt-in native admin pilot, `/libraries`, library and game editors, per-game artwork, and selected scan pages use the shared React shell and existing admin APIs. -**Libraries & scans** destinations are separate pages. The **left rail** lists Libraries, Scan, Library tools, Unmatched, Filters, Release filters, Extensions, Image queue, and Art & images. **Scan** is one LHN row with Auto, Manual, and Jobs sections stacked on the same page in that order. Jobs actions sit with the jobs table; the scan page does not add navigation or action buttons to the thin top bar. Unmatched actions and **Add filter** remain in that bar. Menus close when you click outside or press Escape. +**Libraries & scans** destinations are separate React pages. The **left rail** lists Libraries, Scan, Scan jobs, Library tools, Unmatched, Scan filters, Extensions, Image queue, and Art & images. **Scan** opens the existing Auto and Manual controls. **Scan jobs** opens the React progress/retry view at `/admin/scan-jobs`; its link back to the scan controls preserves the existing start-scan workflow. **Unmatched** opens the React folder review at `/admin/unmatched` at `/admin/unmatched`. **Scan filters** opens the shared React filter manager at `/admin/edit_filters` . It supports name tags, skip-folder globs, and skip-folder regexes with confirmed removal. Jobs actions sit with the jobs table; the scan page does not add navigation or action buttons to the thin top bar. Menus close when you click outside or press Escape. -The Libraries page is an admin SPA **DataTable** (sort + per-column filters, themed `od-cbtn` Scan/Edit/Delete bar, sticky multi-select batch actions). Its **Create library** form is inline on `/libraries`: choose a name, supported platform, mounted folder path, scan depth, then create and queue the first scan. The former “full library forms” self-link has been removed. `/admin/library/add` remains as a classic compatibility route. +With the opt-in native admin pilot enabled, **Automatic scan** (`/admin/scan-controls`) provides the Auto workflow with server-root folder browsing, automatic/folder/file detection, image and metadata options, and one-time or recurring schedules. It submits through the existing scan queue and conflict policy. The same page offers **Manual identify**: select the library, scan location, folder and layout, then choose **List games to identify**. When idle, the read-only request honors the library's scan depth and configured filters without queuing a scan. When another scan is active, it opens the shared Queue/Force conflict dialog; neither a preview nor a scan job is created before the operator chooses. **Identify** opens the React `/add_game_manual` workflow for IGDB and supported provider searches, metadata and taxonomy edits, then submits through the admin-only manual-identify API. `/game_edit/` uses the same React form with a separate guarded update API. `/scan_manual_folder` redirects to the native Manual scan view in pilot mode. **Link existing game** searches the admin library, then asks for confirmation before assigning the listed folder path to the chosen title; any matching unmatched-folder entry is cleared. Provider search, identify-save and link requests have been exercised with intercepted responses, so no provider or database write was made. Flask retains the route, session, authorization and API boundary; page documents use the shared React shell. + +The pilot also serves `/scan_management` directly and honors its `active_tab` query when opening the React scan, jobs, tools, unmatched, filter, extension or image-queue page. The `library_uuid` deep link preselects the scan library. At 375/1440, the isolated review UI has exercised a real folder-proposal candidate, import validation, suggested-fix approval, doctor preview, rename preview, freshness results and the large-batch warning. Consequential confirmations were canceled or their requests intercepted; no scan, provider request, sidecar write, rename or library create ran. Manual Identify Queue/Force, provider search, identify save and existing-game link flows also passed with their network writes intercepted. The per-game image editor supports cover, screenshot and other-artwork management through the current APIs; the desktop cover preview and controls now share a two-column panel, which stacks on phones; upload/delete were intercepted, and the IGDB refresh confirmation was canceled. The `/scan_manual_folder` compatibility redirect was verified at 375/1440. The authenticated desktop route smoke covered 59 registered admin paths/deep links and rendered content without page errors. The full admin-app suite passes 590/590 tests, including component behavior; focused API/schema and native dispatch suites also pass. Auto Scan request fields were verified at both widths with its request intercepted. Actual store-provider connections, scan execution and filesystem Apply remain unrun in this UI review. + +On narrow screens, admin page actions sit on a second top-bar row and long action strips scroll horizontally. The navigation toggle opens a labelled drawer; focus moves into it, and **Close** or Escape closes it and returns focus. In Scan Jobs and Unmatched, Tab moves focus to the named table scroll region and ArrowRight scrolls its columns. + +The Libraries page is an admin SPA **DataTable** (sort + per-column filters, themed `od-cbtn` Scan/Edit/Delete bar, sticky multi-select batch actions). If the library roster fails to load, **Retry loading libraries** retries it while retaining the shared page heading. Its **Create library** form is inline on `/libraries`: choose a name, supported platform, mounted folder path, scan depth and watch preference; optionally upload a JPG/PNG/GIF/WebP image (10 MB max), then create and queue the first scan through the admin API. The former “full library forms” self-link has been removed. `/admin/library/add` opens the shared create form and `/admin/library/edit/` opens the direct editor. Edit reads and saves through the admin library JSON API, including platform, scan depth, watch preference and optional image replacement. The native editor has passed authenticated 375/1440 captures and a disposable save/reload/restore check; broader library tools and scan action parity remain open. ## Where can a library point? @@ -70,12 +76,14 @@ for mounting recipes per OS and for the Docker host-path-vs-container-path trap. ## Add a library 1. Admin → **Libraries & scans** (`/libraries`). -2. In **Create library**, choose a name and platform, set the visible host-mounted folder path (Docker: under `/storage/...`), and choose the scan depth. The form loads its platform options from the server and reports create/scan errors in place. +2. In **Create library**, choose a name and platform, set the visible host-mounted folder path (Docker: under `/storage/...`), and choose scan depth and incremental-watch behavior. Optionally upload a JPG, PNG, GIF, or WebP image up to 10 MB. The form loads its platform options from the server and reports create/scan errors in place. 3. Choose **Create library and start scan** to save the library and queue its first scan; then use Scan management to monitor or tune recurring scans. 4. **Update library when folders change** — per-library incremental watch (`watch_enabled`: follow global / prefer on / opt out). Global `ONEIRODEX_LIBRARY_WATCH` may still gate Unraid (Ops → Library watch). 5. Prefer a small test scan before a full library scan. A new install can skip IGDB credentials and arrives at this same create-library flow; optional integrations can be configured later from Admin → Integrations. -**W22-1 chrome (updated):** Library Management and Scan management share the **Libraries & scans** LHN section. Destinations are separate URLs (`/libraries`, `/scan_management?active_tab=…`). Multi-select libraries (checkbox / select all) → sticky **Scan** / **Edit** / **Delete**. Sticky **Scan** posts `POST /api/admin/libraries/batch/scan` (`library_uuids` + `queue_policy=queue`); per-row and sticky **Edit** open the shared-fields modal on this page → `POST /api/admin/libraries/batch/edit` (`scan_depth` · `watch_enabled` · `platform`; full editor for name/image stays a link inside the modal). Bulk delete offers a **Force delete** checkbox (no typing each name). Prefer the batch APIs below; soft-degrade to sequential single-library calls when a batch route 404s mid-rollout. After theme CSS/JS deploy: **Admin → Themes → Reset Themes** so `library/themes` picks up `admin_manage_libs` — [themes-reset.md](themes-reset.md). +**W22-1 chrome (updated):** Library Management and Scan management share the **Libraries & scans** LHN section. Destinations are separate URLs (`/libraries`, `/scan_management?active_tab=…`). Multi-select libraries (checkbox / select all) → sticky **Scan** / **Edit** / **Delete**. Sticky **Scan** posts `POST /api/admin/libraries/batch/scan` (`library_uuids` + `queue_policy=queue`); per-row and sticky **Edit** open the shared-fields React dialog on this page → `POST /api/admin/libraries/batch/edit` (`scan_depth` · `watch_enabled` · `platform`; full editor for name/image stays a link inside the dialog). Delete opens a React confirmation dialog: type the exact library name for a single delete or `DELETE N LIBRARIES` for a bulk delete; bulk can skip typing with **Skip typing the confirmation phrase**. Confirm posts the existing batch-delete API and tracks background progress. On phones the page scrolls vertically below the create form, and the table keeps its own horizontal scroll area. Prefer the batch APIs below; soft-degrade to sequential single-library calls when a batch route 404s mid-rollout. After theme CSS/JS deploy: **Admin → Themes → Reset Themes** so `library/themes` picks up `admin_manage_libs` — [themes-reset.md](themes-reset.md). + +The React Libraries list can be sorted and filtered. At narrow widths, Tab can enter the named **Libraries table** region and native horizontal scrolling reveals the last columns; each per-row **Scan** action includes the library name for assistive technology. The native page also has a collapsed **Suggested fixes** panel. Open it to load existing proposal sidecars; retry is available after a load error. Each candidate has a separately confirmed **Approve** action; approval clears the proposal sidecar and exposes **Continue identify**, which opens the React identify form with the selected IGDB ID. Approval does not import a game. The page also includes a collapsed **Tidy folder names** workflow. Enter allowed folder roots, preview the rename plan, then choose **Save as suggestions** or **Rename on disk**. Both write actions ask for confirmation; renaming changes real paths and requires a fresh preview after any input change. The native page also includes a collapsed **Rename one game** workflow with admin game search, title/year and naming-pattern options, per-operation checkboxes, and a shared danger confirmation before applying selected paths. Canceling confirmation does not submit the apply request. A collapsed **Check for updates** panel uses a 25-game default batch, can restrict the scope to one library, and skips games checked in the last 24 hours by default. Store requests can be rate-limited; **Refresh up to 500 games** asks for confirmation before starting. **Library tools** (tidy names, proposals, rename, freshness, propose/import leaves): `/scan_management?active_tab=tools` (LHN). Tool views live in the **thin top bar** — **Add many** (Scan a folder | Import a list inside the pane), Tidy folder names, Review suggested fixes, Rename one game, Check for updates. `/admin/library_tools` redirects there. @@ -103,7 +111,7 @@ Admin-only (`@admin_required` + session CSRF as other `/api/admin/*` POSTs). Par ## Delete a library -Admin → **Libraries & scans** → **Libraries** → **Delete** (or multi-select → **Delete**) opens a Bootstrap confirm modal. Single delete: type the library name to enable **Confirm Delete**. Bulk: send exact names via `confirm_names` **or** check **Force delete** (`force=true`) to skip typing. Delete-all (if present) still uses `DELETE ALL LIBRARIES`. Confirm/Cancel must be clickable. If buttons look present but do not respond: rebuild/restart the app (page includes inline stacking CSS), hard-refresh, then optionally **Admin → Themes → Reset Themes** so `library/themes` copies pick up theme CSS/JS fixes — [troubleshooting.md](troubleshooting.md) · [themes-reset.md](themes-reset.md). +Admin → **Libraries & scans** → **Libraries** → **Delete** (or multi-select → **Delete**) opens a React confirmation dialog on the mounted Libraries page. Single delete: type the library name to enable **Confirm delete**. Bulk: send exact names via `confirm_names` or check **Skip typing the confirmation phrase** (`force=true`). Progress is shown while the background job runs. Delete-all (if present) still uses `DELETE ALL LIBRARIES`. Confirm and Cancel remain available by keyboard and pointer. ## Run a scan @@ -177,7 +185,7 @@ Per-row actions: | `POST /api/unmatched_folders/backfill_suggested_kind` | One-shot fill null `suggested_kind` from proposal sidecars (`dry_run?` · `limit?`; idempotent) | | `POST /api/unmatched_folders//fix` | `{action: merge\|keep\|ignore}` — merge clears the row (keep library game); keep → Unmatched; ignore → Ignore. Writes `duplicate_fix_logs` | | `GET /api/unmatched_folders/fix_logs` | Queryable how-matched / how-fixed history | -| `GET /api/path/open?path=` | Path string only for Desktop OS explorer — **no** Auto Scan redirect. Queue reveal via `POST /api/client/commands` `{action:"open_path", path, select?}` (allowlisted under library roots; `game_uuid` optional). | +| `GET /api/path/open?path=` | Path string only for Desktop OS explorer — **no** Auto Scan redirect. Queue reveal via `POST /api/client/commands` `{action:"open_path", path, select?}` (allowlisted under library roots; non-admin game reveals must include an accessible `game_uuid` and stay within that game's recorded folder; unbound unmatched/scan-job reveals require admin access). | **PC extras on scan (Wave 2a):** PC libraries associate common under-game `DLC`/`extras` folders (+ sibling `Title DLC` sidecars). Details `extras[].on_server` is honest — missing disk presence stays false. Console/ROM DLC ingest remains deferred (GM-locked). @@ -185,7 +193,7 @@ The unmatched table stretches with the Scan management page (`od-adminpage--xl`, ## After scan -- **Edit game entry / Identify** groups its fields into tabs — **About** (summary, storyline, developer, publisher) · **Release** (status, release type, rating, date) · **Links & media** · **Taxonomy** (genres, modes, themes, platforms, perspectives). IGDB ID, name, provider search and disk path stay above the tabs, since those are what the page is opened for. A tab whose fields failed validation is marked and opens first, so an error can never hide behind a closed tab. The panels are still server-rendered and post to the same handler; only the tab bar is React. +- **Edit game entry / Identify** groups its fields into tabs — **About** (summary, storyline, developer, publisher) · **Release** (status, release type, rating, date) · **Links & media** · **Taxonomy** (genres, modes, themes, platforms, perspectives). IGDB ID, name, provider search and disk path stay above the tabs, since those are what the page is opened for. A tab whose fields failed validation is marked and opens first, so an error can never hide behind a closed tab. The browser title now follows the context: **Identify game**, **Identify unmatched folder**, or **Edit game entry**. The panels are still server-rendered and post to the same handler; only the tab bar is React. - **Provider links** (Links & media tab) hold one URL per provider — Steam, GOG, Epic, itch, RAWG, MobyGames, TheGamesDB, official site, Wikipedia — stored as the same `GameURL` rows scanning writes, so a link typed here and one IGDB filled in are the same record. Previously only IGDB's URL had a field, so a Steam or GOG page found while identifying had nowhere to go. Blank removes that provider's link; non-http values are refused and the stored one is kept. Rows this form does not own (`youtube`, patch guides) are untouched by a save. - Unmatched titles → Identify workbench (IGDB / Steam / GOG / RAWG / Epic / itch / GiantBomb / MobyGames / TheGamesDB / Meta·Quest via IGDB platforms). IGDB search is filtered by `LibraryPlatform` enum name → `platforms.id` (`igdb_platform_id_for`). Game Boy Color is 22. Unmapped leaves (CreatiVision, Adventure Vision, Studio II, Action Max, Daphne, Pinball) search unfiltered. The edit workbench used to look up the display label and miss every filter. - **Stage D (W20-5a + W21 harden + store providers):** IGDB miss → resolve exact Steam / GOG / Epic hit → **one IGDB retry** with the store’s canonical title → still miss → custom-range Game from that store candidate. Steam App ID path only when store details + title corroborate. Wrong-namespace `(digits)` fall through (no bogus `steam_app_id`). Ambiguous or miss → Unmatched as below. Operator toggles: **Admin → Integrations → Metadata** (`GET|PUT /api/admin/integrations/metadata-providers`; defaults all on). @@ -252,7 +260,7 @@ Two register items from the capability harvest were sized in H1 and deliberately ## Scanning filters -Admin → Scan management → **Scan Filters** (also legacy `/admin/edit_filters`). One list, two behaviors: +Admin → **Libraries & scans → Scan filters** (`/admin/edit_filters`) or the Scan management → **Scan Filters** tab. The direct route now uses the shared React manager with an admin-only JSON API. Legacy form POSTs still redirect with validation/status messages. The former `/delete_filter/` GET link no longer removes data; use the confirmed Remove action, which sends an authenticated DELETE. One list, three behaviors: | Kind | How you enter it | What it does | |---|---|---| @@ -260,7 +268,9 @@ Admin → Scan management → **Scan Filters** (also legacy `/admin/edit_filters | **Skip folder** | Prefix `dir:` (e.g. `dir:_MyTools`, `dir:OpenVR*`) | Folder listing skips matching basenames (case-insensitive **fnmatch**). Built-in emu/FE/tool + scaffolding + mod/repack defaults already apply; Admin rows add extras | | **Skip folder (regex)** | Prefix `re:` (e.g. `re:\[Demo\s+Build\]`) | Folder listing skips basenames matching the regex (case-insensitive). Built-in generic `[… Repack]` bracket-tag regex already applies | -**UI:** How-it-works copy, examples, Kind column, and **Quick add** chips that fill the modal (Name · GOG / Open Source / Public Domain; Skip · `_MyTools` / `OpenVR*` / `Tools*` / `_Extras`). Type toggle auto-prefixes `dir:` for skip rows. Prefer **prefix** globs (`dolphin*`, `GOD v*`) over substring (`*dolphin*`, `GOD*`) so real titles are not skipped. +The React manager validates custom regex syntax before saving. Existing saved patterns remain listed and are not rewritten during migration. + +**UI:** How-it-works copy, examples, Kind column, and **Quick add** buttons that fill the form (Name · GOG / Open Source / Public Domain; Skip · `_MyTools` / `OpenVR*` / `Tools*` / `_Extras`). The filter-type selector supports name tags, skip-folder globs, and skip-folder regular expressions. Prefer **prefix** globs (`dolphin*`, `GOD v*`) over substring (`*dolphin*`, `GOD*`) so real titles are not skipped. Skip-dir is **defense-in-depth only** — still create per-leaf libraries; do not point a lib at a family root and rely on skips. There is no separate filter-kind API column; kind is encoded by the `dir:` or `re:` prefix on `filter_pattern`. Case sensitivity is stored as `'yes'|'no'` on `filters.case_sensitive` (String); both Scan management and legacy Edit filters write that form, and the scan loader accepts legacy bool / `1|0` / true-false strings. After theme CSS/JS updates, **Admin → Themes → Reset Default Themes** so `library/themes` picks up `admin_manage_scanjobs` changes. @@ -293,21 +303,39 @@ Do **not** create one library on a mixed console root (families + emulator insta Never library-root: `_Emulators`, named emu installs, Pegasus/CRU/tools, archive-only parents. Document typo paths as-is (e.g. `Ninentdo Entertainment System`). -**Propose leaf libraries (W20-1):** Admin → **Libraries & scans → Library tools** (`/scan_management?active_tab=tools`) → **Add many: scan a folder**. Enter a root path under allowed bases → **Propose** → multi-select candidates (platform, mode, depth, path, reason) → **Confirm create**. Uses existing `POST /admin/library/add` per selected leaf, then queues a first scan via `POST /api/admin/libraries/scan` so `last_scan_folder` remembers the leaf — **never** auto-creates on Propose; never suggests family mega-lib roots. Pointing at the games mount (`/storage`) walks `_console-gaming` (skip-dir name, still a family tree) and proposes `_pc` as **PCWIN** `folders`/`2`; walkthroughs and emu installs stay out. Soft-degrades if `GET|POST /api/library_tools/propose_leaf_libraries` returns 404 mid-rollout. API returns `{ path, suggested_name, platform, scan_mode, scan_depth, reason }` with `auto_create: false`. Heuristics: flat ROM files → `files`/`1`; title dirs → `folders`/`1`; letter buckets → `folders`/`2`. Switch leaves → `SWITCH` + `folders`/`1` (catalog honesty — no WebRetro CTA invent). `MAME` zip dumps propose as **ARCADE** `files` (not a family parent). +**Propose leaf libraries (W20-1):** Admin → **Libraries & scans → Library tools** (`/scan_management?active_tab=tools`) → **Add many: scan a folder**. In Libraries, open **Add many → Propose from folder**. Enter a root path under allowed bases → **Propose** → multi-select candidates (platform, mode, depth, path, reason) → **Confirm create**. Confirm creates each selected leaf through the validated `POST /api/admin/libraries` API, uses its returned UUID, then queues a first scan via `POST /api/admin/libraries/scan` so `last_scan_folder` remembers the leaf — **never** auto-creates on Propose; never suggests family mega-lib roots. Pointing at the games mount (`/storage`) walks `_console-gaming` (skip-dir name, still a family tree) and proposes `_pc` as **PCWIN** `folders`/`2`; walkthroughs and emu installs stay out. Soft-degrades if `GET|POST /api/library_tools/propose_leaf_libraries` returns 404 mid-rollout. API returns `{ path, suggested_name, platform, scan_mode, scan_depth, reason }` with `auto_create: false`. Heuristics: flat ROM files → `files`/`1`; title dirs → `folders`/`1`; letter buckets → `folders`/`2`. Switch leaves → `SWITCH` + `folders`/`1` (catalog honesty — no WebRetro CTA invent). `MAME` zip dumps propose as **ARCADE** `files` (not a family parent). -**CSV/JSON bulk import (W20-1b):** Admin → **Libraries & scans → Library tools** → **Add many: import a list**. Paste JSON/CSV or upload `.json`/`.csv` → **Preview** → multi-select candidates (row `errors[]` shown separately) → **Confirm create**. API: `POST /api/library_tools/import_leaf_libraries/preview` (JSON body, multipart file, or form `csv`/`text`). Same candidate fields as propose (`path`, `suggested_name`/`name`, `platform`, `scan_mode`, `scan_depth`). Returns `{ auto_create: false, candidates[], errors[], count, error_count, create_hint }`. Hard rejects: family mega-lib parents (`NINTENDO`/`Sega`/`Sony`/…), invalid `LibraryPlatform`, path outside allowed bases. **Never auto-creates** — confirm-create reuses the propose path (`POST /admin/library/add` per selected row + first scan). Soft-degrade UI if the preview route 404s mid-rollout; UI refuses if `auto_create === true`. +**CSV/JSON bulk import (W20-1b):** Admin → **Libraries & scans → Library tools** → **Add many: import a list**. In Libraries, open **Add many → Import CSV/JSON**. Paste JSON/CSV or upload `.json`/`.csv` → **Preview** → multi-select candidates (row `errors[]` shown separately) → **Confirm create**. API: `POST /api/library_tools/import_leaf_libraries/preview` (JSON body, multipart file, or form `csv`/`text`). Same candidate fields as propose (`path`, `suggested_name`/`name`, `platform`, `scan_mode`, `scan_depth`). Returns `{ auto_create: false, candidates[], errors[], count, error_count, create_hint }`. Hard rejects: family mega-lib parents (`NINTENDO`/`Sega`/`Sony`/…), invalid `LibraryPlatform`, path outside allowed bases. **Never auto-creates** — confirm-create calls `POST /api/admin/libraries` per selected row, takes the returned UUID, then queues its first scan through `POST /api/admin/libraries/scan`. The confirm path uses the validated API. A disposable review-library create/readback/delete passed through this API and the native delete dialog; the first-scan request was intercepted, so no scan ran. Soft-degrade UI if the preview route 404s mid-rollout; UI refuses if `auto_create === true`. **Empty shelves (systems with no dumps yet):** Systems tiles come from Library rows, not from the enum list. A leaf with 0 games still shows. Household placeholders (VB / Wii / Wii U / 3DS / Pokémon Mini / Vita / Xbox siblings / Commodore 8-bit / CD-i / Pico / Jaguar CD / Amiga CD32 / MSX / ZX Spectrum / Amstrad CPC / Atari ST / Apple II / Atari 8-bit / X68000 / PC-98 / BBC Micro / Game & Watch) are on the games share as of 2026-08-30. Import [empty-shelf-import.csv](empty-shelf-import.csv) via **Add many: import a list**, then confirm create (never auto). Propose-from-tree is the secondary path. Do not point at a missing path and hope scan invents files. Full folder table + remaining enum-gap list: console-gaming-libraries.md (local-only reference). Oneirodex never fetches ROMs. -**Skip-dir (defense-in-depth, W20-7 handoff #4):** folder listing ignores built-in emu/FE/tool **prefix** globs (`_Emulators`, `yuzu*`, `ryujinx*`, `dolphin*`, `bsnes*`, `pegasus*`, `cru-*`, `GOD v*`, `xenia*`, `zinc*`, `mame0*`, …), emulator scaffolding (`Config`, `Lang`, `Plugin`, `ROMs`, `docs`), scan-root leaks (`_console-gaming`, `_pc`), walkthrough trees, MOD/VR-mod markers, and generic `[… Repack]` bracket-tag folder names (built-in regex). Patterns are mostly **prefix** globs so titles like *God of War* or *Ecco the Dolphin* are not skipped. Operators add extras via Admin → Scan management → **Scan Filters** — **Skip folder** (`dir:` fnmatch, e.g. `dir:_MyTools`) or **Skip folder (regex)** (`re:`, e.g. `re:\[Demo\s+Build\]`) — see [Scanning filters](#scanning-filters). No API route change; kind is encoded by the `dir:`/`re:` prefix on `filter_pattern`. This does **not** replace per-leaf libraries — do not point a lib at a family root and rely on skips. There is no `scan_depth=3` family walker. +**Skip-dir (defense-in-depth, W20-7 handoff #4):** folder listing ignores built-in emu/FE/tool **prefix** globs (`_Emulators`, `yuzu*`, `ryujinx*`, `dolphin*`, `bsnes*`, `pegasus*`, `cru-*`, `GOD v*`, `xenia*`, `zinc*`, `mame0*`, …), emulator scaffolding (`Config`, `Lang`, `Plugin`, `ROMs`, `docs`), scan-root leaks (`_console-gaming`, `_pc`), walkthrough trees, MOD/VR-mod markers, and generic `[… Repack]` bracket-tag folder names (built-in regex). Patterns are mostly **prefix** globs so titles like *God of War* or *Ecco the Dolphin* are not skipped. Operators add extras via Admin → **Scan filters** — **Skip folder** (`dir:` fnmatch, e.g. `dir:_MyTools`) or **Skip folder (regex)** (`re:`, e.g. `re:\[Demo\s+Build\]`) — see [Scanning filters](#scanning-filters). These admin routes store the type prefix in the existing `filters.filter_pattern` column; the scan matcher and folder walker continue reading the same records. This does **not** replace per-leaf libraries — do not point a lib at a family root and rely on skips. There is no `scan_depth=3` family walker. + +The Libraries and household Accounts tables provide named keyboard-focusable +scroll regions on narrow screens. Focus the table region and use the arrow keys +to reach columns; row actions include the library or account name. + +Folder browser rows expose theme-aware folder/file marks. Folder rows are +buttons: use Tab to focus a folder and Enter or Space to open it. + +## Image queue and per-game artwork + +From a game card, details page, or the image queue, **Edit all artwork** opens `/edit_game_images/` . The shared React page displays cover, screenshots and other image kinds; it supports local uploads, confirmed deletion and the existing artwork picker. Refresh from IGDB is a separate confirmed action. Artwork changes pause while a scan is running. + +The legacy image editor uses the shared, theme-aware fallback when a game has +no cover: the game title and **No cover art** stay readable as live text. A +successful cover upload replaces the fallback with its preview and a delete control +for the current image. Replacing the cover updates that control; confirmed deletion +restores the fallback. Cancelling deletion keeps the cover. Upload feedback follows +the page theme. See the +[current image-editor capture](../media/screenshots/admin-image-editor.png). -## Image queue Admin → **Image queue** (`/admin/images`) manages artwork downloads with queue health counts, compact filters, retry/download controls, and artwork search/actions (kinds below). The scan management **Image Queue** tab remains available for the legacy table and its row-level tools; use the dedicated page for the redesigned queue workflow. Prefer **Admin → Settings → Art studio → Pick & queue** (`/admin/art_studio#images`) for the React artwork picker + mass queue chrome. For library hero / site-wide fallback variety, use **Art studio → Backup & stock** (`/admin/art_studio#stock`) — platform packs + stock motifs. Library create/edit forms include a **Choose image** link to that tab. -**Image kinds (BE-DET-10):** persisted on `Image.image_type` as `cover` · `screenshot` · `box` · `cart` · `disc` · `logo` · `hero` · `fanart`. Existing cover/screenshot rows map 1:1. Queue list accepts `?kind=` or `?type=` (rejects unknown). Game list: `GET /api/game_images/?kind=`. Singular kinds keep one primary row per game; screenshots stay multi. UI Edit Images filter chrome lands later (Art/UI). +**Image kinds (BE-DET-10):** persisted on `Image.image_type` as `cover` · `screenshot` · `box` · `cart` · `disc` · `logo` · `hero` · `fanart`. Existing cover/screenshot rows map 1:1. Queue list accepts `?kind=` or `?type=` (rejects unknown). Game list: `GET /api/game_images/?kind=`. Singular kinds keep one primary row per game; screenshots stay multi. The native editor uses this API and the existing upload/delete endpoints. - **Preview** column shows a thumbnail once a file is downloaded and still present on disk; a striped placeholder shows for pending/failed rows. - **Group by game** toggle clusters rows by title with a per-group Failed/Pending count badge, instead of one flat paginated list. Group headers include **Open picker** (Art studio Pick & queue) and **Classic edit**. diff --git a/docs/admin/members-and-invites.md b/docs/admin/members-and-invites.md index df7abfe72..07e50294d 100644 --- a/docs/admin/members-and-invites.md +++ b/docs/admin/members-and-invites.md @@ -30,6 +30,21 @@ with no mail server: the form demanded one, handed it to a mailer that was never configured, and the link — which works perfectly well pasted into a chat window — was never shown to anybody. +## Adjusting household quota + +In **Admin → Invites**, choose a member under **Adjust invite quota** and enter an integer from −1000 to 1000. Positive values add quota; negative values subtract it. The saved total appears after submission and the table refreshes. Existing invite tokens are retained. This preserves the classic editor's additive behavior, including a total below zero after subtraction. + +If the invite roster fails to load, use **Retry loading invites** to request it again without leaving the page. + +## Registration whitelist + +**Admin → Whitelist** grants self-registration to selected email addresses. Add an address, then use the registration-status column to see whether that address already belongs to an account. Addresses are normalized to lowercase; duplicate and invalid addresses are rejected. Removal requires confirmation and removes the whitelist entry, while retaining any existing account. Invites remain another way to grant access. + +The public registration form uses the same acknowledgement for accepted and +ineligible account details so it does not reveal whether an email or username +already belongs to someone. Use the admin registration-status column when +checking a household address. + ## Adding a member with no email at all Some accounts have nowhere to send mail and never will: a child's console login, diff --git a/docs/admin/navigation.md b/docs/admin/navigation.md index 949b83db3..301e5e554 100644 --- a/docs/admin/navigation.md +++ b/docs/admin/navigation.md @@ -23,6 +23,11 @@ sub-section is one rail row; the pages inside it are *not* in the rail. Each page has one home. The Support inbox is under Users; ES-DE / Pegasus export is part of Plugins & exports. +The **System** rail section holds Ops glance, Statistics, Downloads admin, Danger +zone, and Admin help. Statistics is read-only and summarizes library totals, +download activity, invitations, and favourites. Downloads admin lists queued zip +requests; deleting a request requires confirmation and removes the request record. + ## Top bar On Settings pages and Integration destinations, the top bar shows that diff --git a/docs/admin/ops-summary.md b/docs/admin/ops-summary.md index f886e4ead..84c8d5e22 100644 --- a/docs/admin/ops-summary.md +++ b/docs/admin/ops-summary.md @@ -4,9 +4,32 @@ **Audience:** Admins / operators reading Admin → Ops **Endpoint:** `GET /admin/api/ops/summary` (admin session) -**UI:** `/admin/ops` and Dashboard poll ~15s (**silent** auto-refresh — no toast spam). On Dashboard, manual refresh is the **reset icon beside Updated** in the status banner (no duplicate footer timestamp). Dashboard widgets are a 12-column board: drag a widget (not its buttons) to move — the tile follows the pointer via transform and commits on release; resize by dragging the **right edge** (width), **bottom edge** (height) or **bottom-right corner** (both; ~1.85× a cell of travel per step), or use the **S / M / L / Full** buttons that appear on hover. Each widget also has **pin** (locks its position and size; other widgets route around it) and **hide**; **Add widget** in the top bar brings hidden widgets back, including optional ones that start hidden (CPU, Memory, Unmatched folders, Active scan jobs). Focus a widget and use the arrow keys to move it, Shift+arrows to resize. Metric tiles and the health banner grow to fit wrapped text; panels keep their own scroll. No grab bar. **Reset layout** (also restores pins and hidden widgets) sits in the thin top-bar trail as a `od-cbtn` (library-style), beside account. Layout persists in `localStorage` key `od-admin-dashboard-layout-v4`, with pins and hidden widgets beside it under the same key plus `:pinned` / `:hidden` (a key bump also clears sticky bad layouts). Metric tiles fill the row width; panel titles (Host meters, Companions by kind) match metric-label type. A first poll that fails uses the shared status block (Retry) rather than empty host panels. Services table scrolls when the list is long. **Grafana-style observability console** (Member UI + Ops wave Pass A–F): status strip + `issues.items` in **two folds** — **Action required** and **Warning / Info** (empty folds hidden). Banner label/tone follows items: any action-fold item → Action required; else soft items only → Warning / Info; else healthy. Dense metric tiles (load / RSS / db_ping / awake / companions / **library watch** / **library health**), host meters, services/scans/errors tables. Library watch shows **off** + env note when `ONEIRODEX_LIBRARY_WATCH` is unset (default). Library health shows compact score · grade + top factors (honest **n/a** / “not scored yet” when `library.health` absent or thin); the tile border/value tones by `grade` (`good`/`fair`/`poor`, muted **na** when thin/null); when `grade` is **poor**, the factors list gets a light danger-edged cue; when **fair**, a warn-gold left edge (good/na unmarked). Null metrics render as **n/a** (e.g. `load_avg` on Windows). Backend enrichments: `host.load_avg` · `host.process` · `host.db_ping_ms` · `services.awake` · `services.library_watch` · `library.health` · companions `by_kind` + `last_seen`. **Device list (TC-4):** the Companions panel also renders `GET /admin/api/ops/devices` — one row per heartbeat-registered seat (status · kind · name · member · version · last seen), newest first, `online` judged on the same 3-minute window as the tile; *Device list unavailable* when that call fails, an explanatory empty state when no seat has ever checked in. +**UI:** `/admin/ops` and Dashboard poll ~15s (**silent** auto-refresh — no toast spam). On Dashboard, manual refresh is the **reset icon beside Updated** in the status banner (no duplicate footer timestamp). Dashboard widgets are a 12-column board: drag a widget (not its buttons) to move — the tile follows the pointer via transform and commits on release; resize by dragging the **right edge** (width), **bottom edge** (height) or **bottom-right corner** (both; ~1.85× a cell of travel per step), or use the **S / M / L / Full** buttons that appear on hover. Each widget also has **pin** (locks its position and size; other widgets route around it) and **hide**; **Add widget** in the top bar brings hidden widgets back, including optional ones that start hidden (CPU, Memory, Unmatched folders, Active scan jobs). Focus a widget and use the arrow keys to move it, Shift+arrows to resize. On both boards at 760px and narrower, widgets stack at full width and per-widget move, resize, pin, and hide controls are hidden; saved desktop positions and preferences stay unchanged. Metric tiles and the health banner grow to fit wrapped text; panels keep their own scroll. No grab bar. **Reset layout** (also restores pins and hidden widgets) sits in the thin top-bar trail as a `od-cbtn` (library-style), beside account. Layout persists in `localStorage` key `od-admin-dashboard-layout-v4`, with pins and hidden widgets beside it under the same key plus `:pinned` / `:hidden` (a key bump also clears sticky bad layouts). Metric tiles fill the row width; panel titles (Host meters, Companions by kind) match metric-label type. A first poll that fails uses the shared status block (Retry) rather than empty host panels. Services table scrolls when the list is long. **Grafana-style observability console** (Member UI + Ops wave Pass A–F): status strip + `issues.items` in **two folds** — **Action required** and **Warning / Info** (empty folds hidden). Banner label/tone follows items: any action-fold item → Action required; else soft items only → Warning / Info; else healthy. Dense metric tiles (load / RSS / db_ping / awake / companions / **library watch** / **library health**), host meters, services/scans/errors tables. Library watch shows **off** + env note when `ONEIRODEX_LIBRARY_WATCH` is unset (default). Library health shows compact score · grade + top factors (honest **n/a** / “not scored yet” when `library.health` absent or thin); the tile border/value tones by `grade` (`good`/`fair`/`poor`, muted **na** when thin/null); when `grade` is **poor**, the factors list gets a light danger-edged cue; when **fair**, a warn-gold left edge (good/na unmarked). Null metrics render as **n/a** (e.g. `load_avg` on Windows). Backend enrichments: `host.load_avg` · `host.process` · `host.db_ping_ms` · `services.awake` · `services.library_watch` · `library.health` · companions `by_kind` + `last_seen`. **Device list (TC-4):** the Companions panel also renders `GET /admin/api/ops/devices` — one row per heartbeat-registered seat (status · kind · name · member · version · last seen), newest first, `online` judged on the same 3-minute window as the tile; *Device list unavailable* when that call fails, an explanatory empty state when no seat has ever checked in. -**Server logs:** live on **Ops → Full log** (`/admin/ops?open=full-log`). Bookmarks to `/admin/server_logs` / `/admin/system_logs` redirect there. +**Server logs:** live on **Ops → Full log** (`/admin/ops?open=full-log`). Bookmarks to `/admin/server_logs` / `/admin/system_logs` redirect there. The irreversible **Clear logs** action requires the admin API's session CSRF check and records a post-clear audit event. + +**Initial load:** The health banner says “Checking health…” until the first summary arrives. If that request fails, it says “Health status unavailable” alongside the shared Retry state; an absent summary is never presented as “All systems healthy.” + +Ops detail tables display configured path existence/read/write state and a concise latest-log summary. Unknown structured values use a safe “Details available” label instead of stringifying arbitrary objects. + +The healthy status widget uses one board row and grows when warning or action items need space; loading an untouched default layout from the prior three-row status arrangement compacts that empty space while preserving customized widget layouts. In the Services table, Readyz checks show a concise per-check state; **View check data** expands the full response when an operator needs the nested details. + +Status indicators use visible text alongside their visual tone. Missing or +unavailable readings are presented neutrally (for example, `n/a` or +`unavailable`) rather than inferred to mean healthy or failed. This is a UI +presentation rule; some backend state and field contracts remain incomplete, +so it does not mean every source has a defined unavailable state or that +missing readings have been resolved. The examples above describe documented +behavior and do not certify a current live capture. + +Optional LiveKit status remains unknown when the source does not establish an +enabled state. An explicitly enabled but incomplete configuration, or an +explicitly configured but unreachable service, contributes a warning to +`issues`; disabled and unknown states do not invent a service problem. +Malware `enabled`, `block_on_hit`, and ClamAV reachability preserve unknown +values when the source fields are absent. Explicit false values remain false. +The current UI presents unavailable values neutrally; populated rendering of +all these endpoint states still needs review. Top-level keys include `as_of`, `host`, `network`, `issues`, `scans`, `library`, **`services`**, `recent_errors` (plus `*_error` when a section fails). diff --git a/docs/admin/privacy-data-handling.md b/docs/admin/privacy-data-handling.md index d4e108c62..22521614f 100644 --- a/docs/admin/privacy-data-handling.md +++ b/docs/admin/privacy-data-handling.md @@ -61,6 +61,14 @@ listing and downloads require `read:library`; uploads and deletion require `write:library`. RetroArch cheat files follow the same boundary: list/download requires `read:library`, while upload/delete requires `write:library`. PC cheat notes are readable with `read:library`; authoring/deletion stays librarian-only. +Favorite and play-status changes, Discover pins and saved filters, wishlist +requests, wanted-update entries, member preferences and detail layouts, +personal collections, playtime sessions, and on-demand freshness checks also +require `write:library` for Bearer tokens; `read:library` alone cannot invoke +these write routes. Bounded update scans, store ownership links/imports/syncs, +launcher imports, and owned-title matching use the same bearer scope. Free-game +ownership assistance, which may update ownership data and start a store sync, +also uses this scope. Registration invite management requires a signed-in browser session, including the classic invite page. API tokens cannot list, create or delete these invites. Private account summaries and support report reads also require the signed-in @@ -75,6 +83,7 @@ Normal browser account flows and public invite redemption remain available. - No `admin`, `write:library`, or `write:download` — on **session cookies and Bearer tokens** - Cannot mint those scopes (including the Desktop companion preset) +- Existing Bearer-token scopes are checked against the current account role when companion commands are delivered; changing a member to `child` stops lifecycle command delivery even if an older token remains active - `GET /api/acquire/search` returns 403 - Companion download / install / update / uninstall / patch / mod-pack commands are denied - Personal playtime and download history hide games outside the child's current library/content access diff --git a/docs/admin/settings-modules.md b/docs/admin/settings-modules.md index c70eeaae9..01553ade3 100644 --- a/docs/admin/settings-modules.md +++ b/docs/admin/settings-modules.md @@ -2,9 +2,9 @@ > **Doc status:** Active -Admin settings use **grouped rows** at `/admin/settings` (one sheet, section headings, whole row → destination; `?section=` redirects). In the rail, Settings unfolds into four icon sub-sections whose pages are buttons in the top bar — see [navigation.md](navigation.md). Chrome is the React admin top bar (`frontend/admin-app`); many forms remain Jinja until migrated. Live React bodies include Dashboard, Libraries/Scans hubs, Themes, Plugins, Announcements, **Support inbox**, and the **Integrations hub** (the same grouped rows as Settings: Metadata & art · Stores & ownership · Messaging & identity · Acquisition, with deep links into classic forms). +Admin settings use **grouped rows** at `/admin/settings` (one sheet, section headings, whole row → destination; `?section=` redirects). In the rail, Settings unfolds into four icon sub-sections whose pages are buttons in the top bar — see [navigation.md](navigation.md). Chrome is the React admin top bar (`frontend/admin-app`); settings pages use the shared React shell and guarded APIs. Live React bodies include Dashboard, Libraries/Scans hubs, Themes, Plugins, Announcements, **Support inbox**, and the **Integrations hub** (the same grouped rows as Settings: Metadata & art · Stores & ownership · Messaging & identity · Acquisition, with deep links into service settings). The `/admin/integrations` route serves the React overview; Flask owns the route, session and compatibility APIs. -**Integrations inventory API:** `GET /api/admin/integrations/inventory` (admin) returns `{integrations[{id,name,category,status,configured,enabled,admin_href,settings_href,notes}], count, hub_href}` covering IGDB, SteamGridDB, Giant Bomb, HLTB, RetroAchievements, Meta/Quest, SMTP, OIDC, Support, community chat, LiveKit, Arr connectors, and ownership register links — so the hub is not IGDB-only. The React Integrations page renders a **Provider inventory** grouped by category (with status + notes) under the grouped rows. RetroAchievements connects under **Integrations → Metadata & art → Artwork & secondary**; the inventory deep-link uses the supported `#artwork` tab. Classic `/admin/integrations` artwork tab anchors (`#steamgriddb`, `#giantbomb`, `#hltb`, `#retroachievements`, `#meta_quest`, `#ownership`, `#livekit`, `#support`, `#indexers`, `#community`, `#email`, `#igdb`) deep-link the same surfaces. +**Integrations inventory API:** `GET /api/admin/integrations/inventory` (admin) returns `{integrations[{id,name,category,status,configured,enabled,admin_href,settings_href,notes}], count, hub_href}` covering IGDB, SteamGridDB, Giant Bomb, HLTB, RetroAchievements, Meta/Quest, SMTP, OIDC, Support, community chat, LiveKit, Arr connectors, and ownership register links — so the hub is not IGDB-only. The React Integrations page renders a **Provider inventory** grouped by category (with status + notes) under the grouped rows. RetroAchievements connects under **Integrations → Metadata & art → Artwork & secondary**; the inventory deep-link uses the supported `#artwork` tab. The React overview preserves the owned `#artwork`, `#meta_quest`, `#community`, and `#indexers` fragment destinations; React fragment links route to the matching integration panel, including provider inventory, ownership, community, email and IGDB settings. **Fresh-install setup:** The six-step wizard creates the admin, optionally configures SMTP and feature preferences, optionally collects IGDB plus Steam Web API, SteamGridDB, Giant Bomb, MobyGames, and TheGamesDB credentials, and can save OIDC issuer/client details without enabling SSO. It then offers to create a first library and queue its initial scan. The API-key fields are opt-in; empty values preserve existing credentials and secrets are never rendered back into the form. HowLongToBeat can be toggled without a key. OIDC remains disabled during setup so the new admin can verify its issuer and callback before enabling an authentication provider. Keyless catalogue sources continue to work without setup. Integrations that require container environment values (for example LiveKit and *arr endpoints) remain visible in the Integrations inventory and require deployment configuration. Every step can be skipped; setup can finish without a library. @@ -12,6 +12,10 @@ The flow follows the optional, ordered setup used by Plex and Jellyfin: establis **Newsletter:** `/admin/newsletter` stays available when SMTP or the newsletter feature is off. The page explains which prerequisite is missing and keeps sent history readable; sending is disabled until both the feature and a working SMTP sender are configured. +**OIDC / SSO:** The legacy `/admin/integrations/oidc` URL redirects to the React OIDC panel at `/admin/integrations#oidc`. It saves issuer, client, callback, scopes and role mapping through the admin settings API. The API returns only whether a client secret is configured; submitting a blank secret preserves the stored value. Saving settings does not contact an identity provider. The `/admin/integrations#oidc` fragment opens the OIDC panel; other integration settings remain on the same React page or their dedicated React routes. See [OIDC / SSO](../runbooks/oidc-sso.md) before enabling sign-in. + +**Download requests:** `/admin/manage-downloads` lists each member's zip job and only the ZIP basename; the API does not return the stored full path. Deleting a request record requires a confirmation and does not delete a ZIP file. The React page uses the Flask route and guarded API. + **Browser play engine (BP-0 / BP-1):** `GET`/`PUT /api/browser-player-settings` stores admin defaults under `GlobalSettings.settings.browser_player`. The only selectable default today is `webretro`; `emulatorjs` is reserved until that shell ships. Optional bool `nostalgist_nes_pilot` (default off) points **NES only** at `/static/vendor/nostalgist/play.html` (vendored Nostalgist + WebRetro `nestopia` WASM). Toggle it on **Admin → Emulators**. Browse payloads include `browser_player` / `browser_players_available` / `nostalgist_nes_pilot`. See [browser-play.md](../user/browser-play.md) and [browser-play-engines.md](../dev/browser-play-engines.md). **Export packs:** Admin → Settings → Extend → **Plugins & exports** (and member Systems secondary section) download ES-DE `gamelist.xml` (`/api/export/esde`) and Pegasus metadata (`/api/export/pegasus`). Paths are portable under library roots — NAS/home mounts are not leaked. @@ -22,7 +26,7 @@ The flow follows the optional, ordered setup used by Plex and Jellyfin: establis ## Scan / match policy (W20-4) -- **Done (uncommitted):** Admin → Settings → **Scan / match policy** (`/admin/scan_match`) — React `ScanMatchSettingsPage` (Jinja SPA shell) + Settings hub card · admin vitest **7/7** claimed · BE `GET`/`PUT` `/api/admin/scan-match/config` live (scoring / dupe / peel wired · pytest claimed **13+21**). +- **Done (uncommitted):** Admin → Settings → **Scan / match policy** (`/admin/scan_match`) — React `ScanMatchSettingsPage` (shared React shell) + Settings hub card · admin vitest **7/7** claimed · BE `GET`/`PUT` `/api/admin/scan-match/config` live (scoring / dupe / peel wired · pytest claimed **13+21**). - **Persist:** `GlobalSettings` (`settings.scan_match` JSON + `propose_only_scan`). Unset keys use defaults below. - **Defaults:** `match_high_threshold` **0.92** · `match_ambiguous_gap` **0.08** · `dupe_title_threshold` **0.85** · `peel_profile` `conservative` · Stage C safe variant toggles **on** (`enable_year_drop_variant` · `enable_pack_peel_variant` · `enable_edition_peel_variant` · `enable_sequel_numeral_variant`). - **Honesty:** propose-only never auto-imports (even high-confidence). API refuses mega-library / depth-3 family walk keys. `scanThreadCount` stays on Server Settings / worker caps. @@ -39,8 +43,8 @@ Product modules default **on**. Disable during **setup → Features**, under **A **Member chrome (Server Settings):** `showHelpButton` and `showTrailers` default **on**. They are baked into every member SPA shell on load (`data-show-help` / `data-show-trailers`), including Systems / Chat / Collections — not only Discover and Favorites. A missing JSON key must not hide those More destinations. -**Stays off by default:** `OIDC_ENABLED` (SSO/auth). -**Safety locks (also off):** `ENABLE_AI_AUTO_APPLY`, `ALLOW_HARDLINK_APPLY`. +**Stays off by default:** `OIDC_ENABLED` (SSO/auth). +**Safety locks (also off):** `ENABLE_AI_AUTO_APPLY`, `ALLOW_HARDLINK_APPLY`. **Patch catalog:** operator YAML/JSON at `PATCH_CATALOG_PATH` — Oneirodex does not scrape romhacking.net or similar sites. | Env / module | Default | Notes | @@ -81,20 +85,26 @@ Product modules default **on**. Disable during **setup → Features**, under **A - Longest remote prefix wins, so a specific mapping beats a general one. A prefix only matches at a path separator, so `/downloads` never matches `/downloads-old`. - Leave empty when client and app share a filesystem view — unmapped paths pass through untouched. - When no mapping is set and a lookup fails, the preview reason now names the path it tried and points at this setting instead of just saying "not found". -- **Quality / release profiles (P1-12):** `GlobalSettings.quality_profiles` (multi-profile JSON). Admin → **Quality profiles** SPA at `/admin/quality_profiles` (`QualityProfilesPage`: list · set active · new · delete · edit · score probe; Jinja is an SPA shell). APIs: `GET/POST /api/quality-profiles`, `PUT /api/quality-profiles/active`, `GET/PUT/DELETE /api/quality-profiles/`. Active profile scores Arr search and extends scan name-clean with blocked/excluded terms. +- **Quality / release profiles (P1-12):** `GlobalSettings.quality_profiles` (multi-profile JSON). Admin → **Quality profiles** SPA at `/admin/quality_profiles` (`QualityProfilesPage`: list · set active · new · delete · edit · score probe; the Flask route serves the shared React shell). APIs: `GET/POST /api/quality-profiles`, `PUT /api/quality-profiles/active`, `GET/PUT/DELETE /api/quality-profiles/`. Active profile scores Arr search and extends scan name-clean with blocked/excluded terms. ## AI - Env: `ENABLE_AI_ASSIST`, `ENABLE_AI_AUTO_APPLY`, `OLLAMA_BASE_URL`, `OLLAMA_MODEL`. -- Admin AI page: enable + Ollama URL/model (`PUT /api/ai/config`) and Test. +- Admin AI page (`/admin/ai`): React page; enable + Ollama URL/model (`PUT /api/ai/config`), explicit local connection test, match suggestions and doctor notes. Applying a title to an existing game requires explicit confirmation and is disabled unless `ENABLE_AI_AUTO_APPLY=true`; suggestions never change titles by themselves. Flask retains the route and API compatibility boundary. - Ollama-only by default; never required for core library use. +## Arr module + +`/admin/arr` now has a native React page in the optional admin pilot, with indexer add/toggle/delete, bulk import, presets, connector settings, search results, and hardlink preview. Saved connector keys and passwords stay write-only; blank secret fields preserve existing values. Search is explicit, and sending a result to qBittorrent requires confirmation. Hardlink preview is read-only; Apply stays disabled unless the pipeline, helper, and apply environment gates are enabled, and applying previewed rows requires a second confirmation. The Flask route serves the shared React page. + ## Storage / hardlinks - `ENABLE_HARDLINK_HELPERS` and `ALLOW_HARDLINK_APPLY` are **env-only** safety gates (no DB toggle). `ALLOW_HARDLINK_APPLY` stays **off** by product default. -- Admin page: **Settings → Storage** (`/admin/storage`) — React `StoragePage` (Jinja emptied to SPA shell). +- Admin page: **Settings → Storage** (`/admin/storage`) — React `StoragePage` (shared React shell). +- If status cannot be read, helpers and mount readiness remain unknown; Preview and Apply stay disabled until **Retry storage status** succeeds. A rejected preview preserves the source and destination draft. - Status API: `GET /api/storage/status` — `helpers_enabled` · `allow_apply` · `games_path` · `games_exists` / `games_readable` / `games_writable` · `degrade_reason` (RO / apply-off honesty). - Preview / apply: `POST /api/storage/hardlink/preview` · `POST /api/storage/hardlink/apply` (apply gated by helpers **and** `ALLOW_HARDLINK_APPLY`). Preview surfaces readable reasons, including **destination parent not writable (read-only mount?)**. +- Hardlink and proposal-cleanup failures return fixed wording; raw OS exceptions and local filesystem paths stay in server logs as exception classes only. - Hub / page banners explain why Apply is disabled when helpers/apply are off or the games mount is RO. ## LiveKit voice @@ -106,7 +116,7 @@ Product modules default **on**. Disable during **setup → Features**, under **A ## Remote play (Moonlight / BYO host) - Env: `ENABLE_REMOTE_PLAY` (**off** by default), optional `SUNSHINE_BASE_URL` / `WOLF_BASE_URL`, hint vars — see `.env.example`. -- Admin: **Settings → Remote play** (`/admin/remote_play`) or `PUT /api/admin/remote-play/config`. +- Admin: **Settings → Remote play** (`/admin/remote_play`) or `PUT /api/admin/remote-play/config`. The form loads saved settings before enabling Save; failed loading offers Retry and prevents saving defaults. Save errors retain the draft and display the server message. - Members: `GET /api/remote-play/status`; game details **Play via Moonlight** copies host + hints. - **No Wolf/GOW in Oneirodex image** — operator runs Sunshine/Wolf on a GPU host; LAN URLs need `ALLOW_PRIVATE_LAN_URLS=true`. - Plugin registry: `remote_play.moonlight`. @@ -133,6 +143,8 @@ Product modules default **on**. Disable during **setup → Features**, under **A - Admin → Features: enable, solver URL, provider, max tier, **Test solver**; status `GET /api/admin/challenge-solver/status?probe=1`. - Opt-in only — not bulk-enabled with OIDC/AI apply locks. Solver URL validated via `validate_connector_http_url` + `ALLOW_PRIVATE_LAN_URLS`. - Acquire (*arr search / debrid HTTP) retries **once** through the solver when a challenge page is detected and the module is on. +- Solver failure diagnostics retain only the exception class; request URLs, query parameters, and raw exception text stay out of logs and status responses. +- Artwork application and search failures use fixed wording; upstream exception text, URLs and credentials are not included in API responses. ## Ambient lighting (Hyperion.ng / Home Assistant) @@ -140,7 +152,7 @@ Product modules default **on**. Disable during **setup → Features**, under **A - Hyperion: `HYPERION_URL`, optional `HYPERION_TOKEN`, `HYPERION_PRIORITY` (default 50), `AMBIENT_ACCENT_COLOR`. - Home Assistant: `HA_URL`, `HA_TOKEN`, `HA_LIGHT_ENTITIES`, optional `HA_PLAY_SCENE` / `HA_STOP_SCENE`. - Play session start/stop hooks fire-and-forget — never block play launch. Child accounts never trigger lighting. -- Admin → Features: enable, provider, connector fields, **Test**; status `GET /api/admin/ambient-lighting/status?probe=1`. +- Configure in the native **Admin → Features → Ambient lighting** panel or through the environment settings above. The panel stores provider settings through the admin-only config API and never returns saved tokens; blank token inputs preserve the saved credentials. **Test connection** asks for confirmation because it may briefly turn on lights or a Home Assistant scene, and the test does not restore the previous scene. Status: `GET /api/admin/ambient-lighting/status`; `probe=1` contacts the configured connector. The Flask route serves the shared React page. - URLs validated via `validate_connector_http_url` + `ALLOW_PRIVATE_LAN_URLS`. ## Support → GitHub @@ -151,10 +163,10 @@ Product modules default **on**. Disable during **setup → Features**, under **A ## Art studio (cover placeholders) - **Admin → Settings → Art studio** or `/admin/art_studio` (React; admin/ops only). Tabs: **Studio** · **Backup & stock** (`#stock`) · **System marks** (`#marks`) · **Pick & queue** (`#images`). -- Local Pillow renderer — aurora tokens (`--gt-*`), no paid cloud AI. Preview/generate use **artistic** compositions by default (`artistic: true` on `POST /admin/api/art-studio/preview`; optional `artistic: false` for legacy flat A/B). Idle **title scale is 1.3×** (floor 0.85×); the slider always posts `title_scale`. +- Local Pillow renderer — aurora tokens (`--od-*`), no paid cloud AI. Preview/generate use **artistic** compositions by default (`artistic: true` on `POST /admin/api/art-studio/preview`; optional `artistic: false` for legacy flat A/B). Idle **title scale is 1.3×** (floor 0.85×); the slider always posts `title_scale`. - **Title-first studio:** large live preview stage; typing a title debounces preview. System / platform selector + preview size toggles (200×300 · 400×600 · 960×540 wide). Soft-fails preview lag with toast. - **Actions:** Preview · Generate pack · Download ZIP · Set as fallback · Apply to game UUID. -- **Backup & stock** (`#stock`): thumbnail grid of platform packs + stock motifs from `GET /admin/api/art-studio/stock`; ungenerated packs auto-call `POST …/stock/generate` on apply. Select → preview → **Use as library default** / **Set fallback** via `POST /admin/api/art-studio/apply` (`mode=fallback|library`). Soft empty state if catalog 404. Library create/edit Jinja **Choose image** links here. +- **Backup & stock** (`#stock`): thumbnail grid of platform packs + stock motifs from `GET /admin/api/art-studio/stock`; ungenerated packs auto-call `POST …/stock/generate` on apply. Select → preview → **Use as library default** / **Set fallback** via `POST /admin/api/art-studio/apply` (`mode=fallback|library`). Soft empty state if catalog 404. The library create/edit React form links here. - **Library default covers** panel shows current `default_cover.jpg` / `default_library.jpg` with **Regenerate defaults** CTA. - **Batch placeholders** (collapsed) for no-cover titles via `POST /admin/api/art-studio/batch-generate` (alias: `apply-batch`) first, then `POST /admin/api/covers/batch/apply` (`generate_only`) fallback. - **Auto-pick (ImagesPage):** `POST /admin/api/covers/batch/apply` with `policy=sgdb_then_igdb_then_generate` (library / platform / service filters). Mass search: `POST /admin/api/covers/batch/search`. @@ -162,11 +174,12 @@ Product modules default **on**. Disable during **setup → Features**, under **A - Remote artwork downloads are streamed and capped at **25 MiB**; a larger provider response is refused before it is written to the library. - Queue rows show `failure_reason` (and `last_error` fallback); list responses surface `image_save_path.error` when the images volume is not writable. - API: `POST /admin/api/art-studio/preview|generate|apply|apply-batch`, `GET /admin/api/art-studio/download/`, `POST /admin/api/art-studio/batch-generate`, `GET /admin/api/art-studio/stock`, `POST /admin/api/art-studio/stock/generate`, `GET /admin/api/art-studio/system-marks`, `GET /admin/api/art-studio/system-marks/lab`, `POST /admin/api/art-studio/system-marks/generate`; covers mass tools under `/admin/api/covers/*`. -- **Systems hub marks** (AI): Art Studio tab **System marks** (`#marks`) lists per-theme progress and can generate missing / force-redo via `GET/POST /admin/api/art-studio/system-marks`. The **Lab** on that tab generates **one** theme×platform pair (editable prompt, preview, attempt log) via `GET …/system-marks/lab` and `POST …/generate` with `platforms` + optional `prompt`. Square WebPs land under `static/library/system-marks//.webp`. Requires `ENABLE_AI_ARTWORK` + `AI_ARTWORK_URL`. CLI: `python scripts/generate_system_marks.py --all`. Full batch regen is ops-gated (quality hold). +- **Systems hub marks** (AI): Art Studio tab **System marks** (`#marks`) lists per-theme progress and can generate missing / force-redo via `GET/POST /admin/api/art-studio/system-marks`. The **Lab** on that tab generates **one** theme×platform pair (editable prompt, preview, attempt log) via `GET …/system-marks/lab` and `POST …/generate` with `platforms` + optional `prompt`. Square WebPs land under `static/library/system-marks//.webp`. Existing art in retired theme folders remains available as a compatibility fallback and is included in progress; the member Systems page tries canonical art, compatible legacy art, then the per-system SVG glyph. Generating new art still requires `ENABLE_AI_ARTWORK` + `AI_ARTWORK_URL`; no automatic generation occurs when a mark is missing. CLI: `python scripts/generate_system_marks.py --all`. Full batch regen is ops-gated (quality hold). - **System templates:** generated covers use per-system palette + glyph (NES/SNES/PS1/Switch/PC/…) so 200×300 tiles stay readable — not a generic subtitle-only placeholder. - **Meta Quest Store:** identify `GET /api/search_metadata?source=meta_quest|meta|quest` · ownership CSV `POST /api/ownership/meta_quest/csv` · `META_QUEST_API_MODE` / `META_QUEST_UNOFFICIAL_GRAPHQL` (off by default) (local strategy notes). - **Store connections (admin):** `/admin/ownership` (Integrations → Ownership registers) lists each member's store links with one state per store (Linked, Syncing, Partly synced, Reconnect needed, Sync failed, …), the last sync's redacted reason ("Admin can fix" marks operator-side causes such as a refused household token or a missing server key), record counts and which sign-in a sync uses (member / household / **unknown** — a GOG, Amazon or Xbox sign-in saved before LIB-04 on a server that has a household token for that store; older versions stored the household token on member accounts unmarked, so ask those members to reconnect after upgrading). It never shows tokens, store account IDs or title lists. **Retry sync** runs a member's sync with their own saved sign-in (not offered when only the member can fix it); **Stop sync** is offered only for GOG / Epic / Amazon, which stop between pages. Household configuration appears as presence flags. API: `GET /api/admin/ownership/connections`, `POST …///sync|cancel`. - **Ownership register:** members link Steam / GOG / Epic / Amazon under **More → Ownership**. Steam Web API, unofficial GOG Galaxy refresh token, unofficial Epic device auth, unofficial Amazon Nile/Heroic token — IDs and names only, never a download. Household env: `STEAM_WEB_API_KEY`, `GOG_REFRESH_TOKEN`, `EPIC_DEVICE_AUTH`, `AMAZON_REFRESH_TOKEN` / `AMAZON_DEVICE_SERIAL` / `AMAZON_NILE_JSON`. **Xbox and PlayStation (INSP-42) are register-only, unofficial and opt-in:** CSV import always works; live sync runs only when `ENABLE_UNOFFICIAL_STORE_SYNC` names the store (`xbox,psn` or `all`; unset = off, decision G5) *and* the optional client is installed (`requirements-optional.txt`: `xbox-webapi`, `psnawp`) *and* a member saved a token on the Ownership page (`xbox-webapi` tokens JSON; PSN `npsso`). Without any of the three the sync button says which is missing in one sentence; the Integrations rows and plugins `store.xbox` / `store.psn` read *disabled* until opted in, *available* until the client exists. Household fallbacks `XBOX_TOKENS_JSON`, `XBOX_CLIENT_ID` / `XBOX_CLIENT_SECRET`, `PSN_NPSSO`. IDs and names only; a 401 fails closed; nothing is ever downloaded. Nintendo stays CSV. +- **Xbox optional-client security limitation:** `xbox-webapi` currently depends on `ecdsa`, which has no patched release for the [Minerva timing advisory](https://github.com/tlsfuzzer/python-ecdsa/security/advisories/GHSA-wj6h-64fc-37mp). The Oneirodex Xbox refresh/title-history path uses a regular `httpx` client and does not create the package's signed session; its `xbox-authenticate` utility does use the affected signer. Prefer CSV import and leave Xbox live sync disabled until an upstream fix or reviewed replacement is available. - Disk failures (read-only `IMAGE_SAVE_PATH` / generated-pack folder, out of space) surface as a JSON `error` and show in the red alert banner instead of a bare 500 — check the message for the exact path/permission problem. If applying a pack to a game fails partway (DB error after the file was written), the orphaned file is cleaned up automatically. - Artwork picker: [steamgriddb-artwork.md](../runbooks/steamgriddb-artwork.md) · [libraries-and-scans.md](libraries-and-scans.md#image-queue). @@ -284,5 +297,62 @@ Related: [libraries-and-scans.md](libraries-and-scans.md) · [docker-compose-dep - **API:** `GET/POST /api/tokens`, `DELETE /api/tokens/{id}` — any logged-in member. - **Presets:** `POST` body `"preset": "companion"` (`read:library` + `write:download`) or `"preset": "thin"` (`read:library` + `read:social` + `write:presence`; **no** download). List response includes `scope_presets`. +- **Library writes:** Bearer tokens need `write:library` to change favorites or play status, save Discover pins or filters, create or cancel wishlist requests, manage wanted-update entries, update member preferences or detail layouts, edit personal collections, start/update playtime sessions, run on-demand freshness checks or bounded update scans, manage store ownership links/imports/syncs, import launcher libraries, match owned titles, or start a free-game ownership assist. The companion and thin presets do not include this scope; API clients that need these writes must create a custom token through `POST /api/tokens` with `scopes: ["read:library", "write:library"]`. `read:library` alone remains read-only for these routes; browser sessions keep their existing CSRF-protected behavior. - **Thin protocol:** heartbeat accepts `device_kind` (`companion` | `thin` | `browser`); `GET /api/client/capabilities` advertises allows/denies. Install/update command queue delivers only to `companion` + download/lifecycle scopes. - **UI:** member SPA **Account → API tokens** (`/tokens`) — create with companion/thin presets, copy one-time secret, revoke. API / `@oneirodex/api-client` / OpenAPI still work. See [desktop-companion.md](../user/desktop-companion.md) · [thin-client.md](../user/thin-client.md). + +## Newsletter + +`/admin/newsletter` uses the shared React page, and numeric `/admin/newsletter/` links open message history details directly. Sending requires both `enableNewsletterFeature` in Server Settings and an enabled SMTP server with a sender. History remains readable while either prerequisite is off. Recipients are existing member email addresses; send requests are confirmed, validate recipients and content, persist a pending record before delivery, and record a truthful sent or failed result. Message content is displayed as escaped plain text. Delivery errors in historical detail are summarized without exposing raw transport text. Flask retains the validated legacy POST contract for compatibility. + +## React admin page behavior + +These behaviors are available through the React admin pages. Flask continues to own route and session compatibility; Jinja templates have been retired. + +### SMTP + +The React admin page provides the SMTP form at the existing URL. Saved passwords are never returned to the browser; leave Password blank to keep the stored credential. Save settings before using Test connection. That action checks the saved server connection and authentication without sending email; success does not establish delivery. A failed load disables saving and offers Retry; a failed save preserves the draft. The Flask route continues to own session, validation and compatibility behavior. + +### Scan / match policy recovery + +The React admin page serves the existing Scan / match policy page. Numeric controls require a value within their displayed bounds. Loading and saving prevent concurrent edits; a failed save retains the draft. If policy loading fails or fields are unavailable, Retry loading settings requests fresh state. Saving policy does not start a library scan. + +### Settings hub and plugin registry + +The React admin page now serves Settings and Plugins at their existing URLs. Settings sections support arrow keys, Home and End while retaining the existing destination links. The registry table supports sorting and a named keyboard scroll region; a failed registry load offers Retry without retaining the loading indicator. This page reports registered connectors and does not install plugins. + +### Detail layout and emulator preferences + +The React admin page serves both pages at their existing URLs. Detail layout rows wrap on narrow screens and visibility controls are named for their section. Layout Save and Reset defaults prevent concurrent edits; failed saves keep the draft. Preferred emulator cores use shared controls and preserve the selected values when saving fails. Browser player, Achievements and Firmware panels remain available through the existing keyboard-accessible tabs. + +The React admin page also serves Store connections at `/admin/ownership`. Its loading state uses the shared page shell and its table has a named scroll region. Repair controls remain busy until their follow-up diagnostics reload finishes. + +### Additional static admin pages + +The React admin page now serves Dashboard (including `/admin`), Ops, Art Studio, Art & images and Danger zone at their existing URLs. Art Studio sections support arrow keys, Home and End with tab/panel associations. Artwork pages share the common Page layout. Image target query parameters and Art Studio section fragments remain supported. Existing preview/confirmation gates on Danger zone remain required. + +### Attract mode + +The React admin page provides the complete Attract mode form even while the feature is disabled. It preserves idle timeout, library/genre/theme/year filters, autoplay and skip settings, and configuration preview. Save validates numeric bounds and year ordering. Failed loading offers Retry and blocks saving; a failed save keeps the draft. Member trailer/autoplay overrides continue using their existing endpoints. + +### IGDB metadata + +The React admin page preserves the IGDB settings URL. Saved secrets are never returned; a blank secret field keeps the stored value. Show entered secret reveals only a new draft and resets after a successful save. Test connection requires saved credentials and no unsaved changes, then contacts IGDB explicitly. Failed loading offers Retry and blocks saving; failed saving keeps the draft. Provider setup instructions link to the official developer console. + +### Household chat emoji + +The React admin page preserves the custom emoji URL and upload constraints. Installed emoji appear in a sortable, filterable table with image previews. Deletion requires confirmation; cancelling preserves the emoji. Failed loading offers Retry and prevents upload. Invalid uploads retain entered values. + +### Features + +The React admin page preserves seven household preferences and reports process flags as read-only. Authentication, automatic AI apply, hardlink apply and other opt-in process settings cannot be enabled by this form. Challenge solver settings save separately; saving no longer automatically probes the connection. Test solver explicitly checks the saved enabled connection and is blocked while the draft differs from it. Failed saves keep the draft and failed loading offers Retry. Scanner status retains readiness diagnostics, with generic connection errors and no local socket path in browser state. + +### Shared page identity + +Admin React pages retain a compact visible heading. Settings and Integration section strips bring the active destination into view on entry or resize without moving keyboard focus or scrolling the page vertically. Features and Chat Emoji use their Integration navigation context. + +## Server Settings + +`/admin/new_server_settings` uses a shared React form with all 28 server, scanning, local metadata and interface controls. Save submits only edited fields through `GET`/`PUT /api/admin/server-settings`, preserving unrelated settings. The API returns only this form's public values and scrubs historical URL credentials. + +Updates and extras folders may use relative nested paths; absolute paths and parent traversal are rejected. The metadata filename remains a single `.json` filename. Configured scan/image limits remain subject to runtime worker caps. Saving preferences does not start scans, contact metadata providers, write metadata files or delete game files. Failed saves preserve the draft; failed loads offer Retry. Flask retains the form POST compatibility handler. diff --git a/docs/admin/support-inbox.md b/docs/admin/support-inbox.md index e1fae3b9a..b3492953b 100644 --- a/docs/admin/support-inbox.md +++ b/docs/admin/support-inbox.md @@ -41,3 +41,7 @@ Global settings: `admin_notify_support` (and related `admin_notify_*` for librar - `GET /api/announcements` — `{ announcements, empty }` (+ `body_preview` per row) - `GET /api/news/gaming` — `{ items, empty }` (never 500 on RSS failure) - `GET /api/news/free-games` — `{ items, enabled, connected_stores, empty }` + +## React admin page behavior + +Support Inbox is served at the existing URL through the shared React admin shell. Loading and errors use the shared page shell, with Try again after a failed list request. Resolve controls identify their ticket and prevent overlapping requests; failures leave the ticket visible with an error. Table sorting/filtering and GitHub links retain their existing behavior. diff --git a/docs/admin/themes-reset.md b/docs/admin/themes-reset.md index 21c17294e..d71ce653b 100644 --- a/docs/admin/themes-reset.md +++ b/docs/admin/themes-reset.md @@ -2,12 +2,16 @@ > **Doc status:** Active -Themes live on the **library volume** (`/app/oneirodex/static/library/themes/...`), not only in the image. Source of truth for defaults: `oneirodex/setup/default_theme/`. Preset generation uses **`GENERATOR_VERSION` 42** in `oneirodex/utils/preset_themes.py`; the new catalogue replaces the former built-in presets with six redesigned dark themes. On startup, only old folders carrying Oneirodex's own preset marker are retired; uploaded themes remain. **Reset Default Themes** installs the six current designs on the volume. +Themes live on the **library volume** (`/app/oneirodex/static/library/themes/...`), not only in the image. Source of truth for defaults: `oneirodex/setup/default_theme/`. Preset generation uses **`GENERATOR_VERSION` 44** in `oneirodex/utils/preset_themes.py`; the new catalogue replaces the former built-in presets with six redesigned dark themes. On startup, only old folders carrying Oneirodex's own preset marker are retired; uploaded themes remain. **Reset Default Themes** installs the six current designs on the volume. ## Uploading a theme Theme ZIP uploads are limited to 25 MiB compressed, 100 MiB total expanded content, and 1,024 archive entries. Unsafe archive paths and special files are rejected before extraction. Each upload uses its own temporary directory; concurrent uploads cannot mix files. If two uploads use the same theme name, one is accepted and the other is refused without replacing the installed theme. +## Admin theme management UI + +`/admin/themes` provides the installed-theme inventory, ZIP upload, custom-theme deletion, loading-motif selection and confirmed default reset in the shared React admin shell. Upload keeps the existing ZIP limits and archive safety checks; loading motifs use the shared catalogue and settings API. Theme choice for an individual account remains in Preferences. Upload, delete, and reset form posts now dispatch through FastAPI with the existing signed-session and CSRF checks, redirects, and flash behavior. Reset also accepts the React JSON request. The authoring guide is served from React at `/admin/themes/readme`. + ## Default look - Accent **`#2fd67b`** (green glass Style B+C). @@ -62,15 +66,15 @@ Or delete `themes/default` (and stale presets) under the library volume and rest | Admin / library color theme CSS (`gt-*` tokens, swatch grid) | `oneirodex/setup/default_theme/` → copied onto the **library volume** on boot | Needs **Reset Default Themes** (or delete `themes/default` + restart) — a plain rebuild alone does not overwrite already-installed volume copies | | Scan management CSS/JS (`admin_manage_scanjobs.css` / `.js` + **W22-1** `admin_manage_libs.css` / `.js` — Libraries tab merge, multi-select sticky **Scan**/**Edit** → batch APIs, force-delete, unmatched Open path, table scale, **UID-005** per-entry top actions bar + Resolve equal-pill bar + sortable columns, **UID-016** Dupe glance + Unmatched side-by-side Compare (This folder \| Library game · path/size/date) + **2026-08-26 Pop out** dialog, **Library tools tab**, **Scan Filters** how-it-works / quick-add / `dir:` kind UI, Wave 17 **Search name**/batch/Dupe-of, **Wave 18** scan job elapsed/ETA/stalled + status/library/path filters, **W20-2** Name transform trail `
    `, **Layout** chips / `od-toast-host`, **UI-W22-M7** Soft title / Utility kind labels on Unmatched / Dupe glance / mark-kind) | Same theme tree → **library volume** copies | Needs rebuild **and** **Reset Default Themes** (or hard-refresh alone is not enough if volume copies are stale) — without Reset, unmatched tab can look cramped / miss Open path chrome / miss Pop out / miss Library tools tab JS / miss top actions bar · Resolve pill bar · column sort / miss UID-016 side-by-side Compare / miss Scan Filters explain UI / miss Wave 18 timing+filter chrome / miss W20-2 transform trail expander / miss W22 Libraries tab + sticky Scan/Edit batch wire + force-delete / miss Soft title·Utility kind copy (still show “experience”/opaque labels) | | Auth / account / identify / Edit Images aurora (`od-setup` · `od-account` · prefs modal · identify `od-adminpage` · **Wave 19** `game_edit_images` aurora + chip underline/scale · **2026-08-25** uploaded-image DOM builders, no `innerHTML` URL interpolation) | Theme tree CSS/templates/JS → **library volume** copies | Needs rebuild **and** **Reset Default Themes** after aurora / Edit Images deploy — without Reset, login/account/identify/Edit Images can still show pre-aurora chrome **and the XSS-safe image insert will not be live** | -| P1 densified-admin Jinja (`od-adminpage` logs/status/whitelist/library-create/SMTP/integrations/users/… · `admin-pages.css` `od-meter` · `admin-components.css` 1600px settings) | Theme tree CSS/templates → **library volume** copies | After P1 densify deploy: **Reset Default Themes** so `library/themes` picks up widened settings shells and aurora meters — otherwise operators still see the 1100px / hybrid glass chrome | +| P1 densified admin theme styles (`od-adminpage` logs/status/whitelist/library-create/SMTP/integrations/users/… · `admin-pages.css` `od-meter` · `admin-components.css` 1600px settings) | Theme tree CSS/templates → **library volume** copies | After P1 densify deploy: **Reset Default Themes** so `library/themes` picks up widened settings shells and aurora meters — otherwise operators still see the 1100px / hybrid glass chrome | | Member SPA bundle (`member-app.css` / `.js` — FilterBar `od-btn*` · **UID-009** `ScrollJump` · locale dates, CSS toasts, Acquire indexer labels · **UID-001** BadgeStack / GameCard layout JS) | Built into the **image/dist**, not the theme volume | Needs image rebuild that includes a fresh `frontend/member-app` dist — **Reset Themes alone does not refresh SPA JS/CSS** | | Admin SPA bundle (`admin-app.css` / `.js` — Settings hub grouped rows · On/Off pills · Server Settings nested-card flatten) | Built into the **image/dist**, not the theme volume | Needs image rebuild that includes a fresh `frontend/admin-app` dist — **Reset Themes alone does not restyle `/admin/settings`** | | Member chrome theme mirror (**UID-009** `od-chrome.css` — ScrollJump glass top/bottom controls) | Theme tree CSS → **library volume** copies | Needs rebuild **and** **Reset Default Themes** after ScrollJump / chrome deploy — without Reset, jump controls can miss aurora glass tokens from stale volume CSS | | Library tile badge chrome (**UID-001** — rounded-square `.od-badge` / stacks / platform chip / hamburger · favorite · status in `css/components.css` + Signals filter chips in `games/library_filters.css`) | Theme tree → **library volume** copies | Needs rebuild **and** **Reset Default Themes** — without Reset, tiles can keep old pill/circle badge chrome or empty reserved corner slots from stale volume CSS | -| Admin SPA SoT re-exports (`stageECandidates.js` / `unmatchedTriage.js` / `scanJobsDom.js` used by Dupe glance / unmatched triage / Libraries & scans poll) | Theme tree JS is SoT; `Dockerfile` `frontend-build` **COPY**s those files into `/build/oneirodex/...` before admin-app `npm run build` | Image rebuild picks up admin bundle + theme JS together — still **Reset Themes** for Jinja scanjobs volume copies of the same files | +| Admin SPA SoT re-exports (`stageECandidates.js` / `unmatchedTriage.js` / `scanJobsDom.js` used by Dupe glance / unmatched triage / Libraries & scans poll) | Theme tree JS is SoT; `Dockerfile` `frontend-build` **COPY**s those files into `/build/oneirodex/...` before admin-app `npm run build` | Image rebuild picks up admin bundle + theme JS together — Reset Themes refreshes mutable theme-volume assets | | Browser Play room skins (`play-skins.css` / `play-skins.js` / `webretro.html`, atmosphere stack · bezel mat · aspect-lock vars) | `oneirodex/static/vendor/webretro/` — served **directly from the image**, not copied to the library volume | Picks up on a normal `docker compose build --no-cache && docker compose up -d` — **no** Reset Default Themes needed, but the browser tab needs a hard-refresh (Ctrl+F5) to drop cached CSS/JS | | Modal stacking helper (`js/od_modal_stack.js`) + `modal-components.css` | Copied onto the **library volume** with the theme tree | Needs rebuild **and** Reset Default Themes (or rely on **inline** stacking CSS in `base_admin.html` / Libraries / Integrations, which works after rebuild alone) | -| Local vendor CDN scrub (`static/vendor/bootstrap/5.3.2/` + jquery/datatables/notify/cropper/sortable/chart under `/static/vendor/...`) on `base_admin` · member `base.html` · `base_empty.html` (login extends `base`) | Served from the **image/static**, not the theme volume | Picks up on rebuild/restart — **no** Reset Themes for vendor libs; still Reset Themes for theme-volume CSS/JS (e.g. `admin_manage_libs.js` typed-confirm delete modal) | +| Local vendor CDN scrub (`static/vendor/bootstrap/5.3.2/` + jquery/datatables/notify/cropper/sortable/chart under `/static/vendor/...`) on `base_admin` · member `base.html` (login extends `base`) | Served from the **image/static**, not the theme volume | Picks up on rebuild/restart — **no** Reset Themes for vendor libs; still Reset Themes for theme-volume CSS/JS (e.g. `admin_manage_libs.js` typed-confirm delete modal) | | Account / prefs chrome (`od-account.css` · prefs modal · `modal-components`) | Theme tree → **library volume** | After Wave 2c densify: if account/prefs chrome still looks pre-sectioned or modal stacking lags, run **Reset Default Themes** (volume copies stale) — same gate as aurora scrub row above | | Wave 2d presets + loading motifs (`GENERATOR_VERSION` **10** at the time · paired `default_icon_pack` · `od-loading-motifs.css` / `od_loading_motifs.js`) | Theme tree tokens/CSS/JS → **library volume** | After Wave 2d ship: rebuild **and** **Reset Default Themes** — without Reset, presets stay accent-only (v9) and Auto Scan misses motif CSS | | Geometry packs (`GENERATOR_VERSION` **16** — UID-006 radius / space / type / shadow per preset) | Theme tree tokens → **library volume** | After 2026-08-26: rebuild **and** **Reset Default Themes** — without Reset, presets keep v10 geometry (hue + pairing only) | @@ -83,7 +87,7 @@ Or delete `themes/default` (and stale presets) under the library volume and rest | Member selection top bar (`od-shell.css` — keep bar visible while `.is-selecting`; larger hover clip-margin; tighter top-bar start padding) | Theme tree CSS → **library volume**; selection bar logic in **member-app** dist | After 2026-08-31 pickup: rebuild **and** **Reset Default Themes**. | | Thin top-bar no fade (`GENERATOR_VERSION` **24** — `od-shell.css` / `od-era.css`: bar stays opaque; hover lifts the scroll pane so tiles overlap it) | Theme tree CSS → **library volume** | Rebuild **and** **Reset Default Themes**. | | Admin trail Reset layout (`GENERATOR_VERSION` **25** — `od-appbar.css`: no rest border on `.od-topbar__trail > .od-cbtn-group > .od-cbtn`) | Theme tree CSS → **library volume**; Dashboard drag/resize pitch is **admin-app** dist | Rebuild **and** **Reset Default Themes**. | -| Token pass on classic CSS (`od-tokens.css` family marks, scan jobs / game details / base / account / setup, plus earlier form-components / sidebar / table-components) | Theme tree CSS → **library volume** | After UID-017: **Reset Default Themes** so volume copies pick up `var(--od-radius-*)` / `var(--od-font-*)` / `--od-family-*`. Member SPA pages ship in **`member-app` dist** — rebuild the image; Reset Themes does not refresh those | +| Token pass on shared theme CSS (`od-tokens.css`, scan jobs / game details / account / setup, plus form-components / sidebar / table-components) | Theme tree CSS → **library volume** | After UID-017: **Reset Default Themes** so volume copies pick up `var(--od-radius-*)` / `var(--od-font-*)` / `--od-family-*`. Member SPA pages ship in **`member-app` dist** — rebuild the image; Reset Themes does not refresh those | | Libraries & scans LHN/THN (`GENERATOR_VERSION` **32** — `od-shell.css` hub sub active matches member; Scan THN Auto\|Manual only; no Libraries sibling strip) | Theme tree CSS/templates → **library volume**; admin-app dist in **image** | After 2026-09-12 chrome pickup: rebuild **and** **Reset Default Themes**. | | Auto Scan refresh control (`GENERATOR_VERSION` **33** — icon + `od-tip` in Auto Scan header; drops the “Refresh all libraries” banner row) | Theme tree CSS/templates → **library volume** | After 2026-09-12: rebuild **and** **Reset Default Themes**. | @@ -93,6 +97,8 @@ Or delete `themes/default` (and stale presets) under the library volume and rest | Segmented strips wrap (`GENERATOR_VERSION` **39** — `.od-seg` gets `flex-wrap`; a six-item strip no longer pushes Admin → Integrations sideways on a phone) | Theme tree CSS → **library volume** | Rebuild **and** Reset Default Themes. | | Hash-identify switch (`GENERATOR_VERSION` **41** — `admin_metadata_providers.js` on the volume reads a fourth checkbox, `mp_hash_identify`) | Theme tree JS → **library volume** | Rebuild **and** Reset Default Themes — without Reset the Integrations form shows the checkbox but the old handler never saves it. | | Theme redesign (`GENERATOR_VERSION` **42** — six replacement dark designs, new avatars, old marked built-ins retired) | Preset folders + theme tree CSS → **library volume** | Rebuild **and** Reset Default Themes — old preset choices migrate to replacements, uploaded themes stay installed. | +| Preferences preview rollback (`GENERATOR_VERSION` **43** — `preferences_modal.js` restores the paired icon-pack setting and stylesheet when an unsaved theme preview closes) | Theme tree JS → **library volume** | Rebuild and **Reset Default Themes** so Cancel/Escape cannot leave the preview icon pack active. | +| Shared filter chips (`GENERATOR_VERSION` **44** — `od-primitives.css` adds token-based responsive `FilterChipGroup` styling) | Theme tree CSS → **library volume** | Rebuild and **Reset Default Themes** so the shared member filter control receives its 44px touch target, selected state, and focus ring. | If a fresh Unraid pull looks half-applied (new play-skins room art shows but admin still looks old, or vice versa), check which of the two you skipped. @@ -139,3 +145,8 @@ Household spinner mode is **DB settings**, not theme volume files — Reset Them - Details: [settings-modules.md](settings-modules.md#loading-icons-admin-lock--rotate). Related: [preferences-themes.md](../user/preferences-themes.md) · [ui-wave0-tokens.md](../dev/ui-wave0-tokens.md) · [unraid-deploy.md](../runbooks/unraid-deploy.md#deploy-gates-operator-checklist) + +The classic image editor’s shared no-cover fallback requires the current +`components.css`, `games/game_edit_images.css`, and `game_edit_images.js` on the +theme volume. Rebuild and **Reset Default Themes** to refresh these copies; its +Flask-built shell markup ships with the application image. diff --git a/docs/admin/troubleshooting.md b/docs/admin/troubleshooting.md index 9cd3e41b8..52d190981 100644 --- a/docs/admin/troubleshooting.md +++ b/docs/admin/troubleshooting.md @@ -28,7 +28,7 @@ docker compose build --no-cache && docker compose up -d ## SPA navigates but pages/admin hang (Discover stuck on Loading) -Default Docker uses **`UVICORN_WORKERS=1`**. A long-lived sync SSE on `/api/activity/stream` used to run through WsgiToAsgi and **starve** Discover, Library APIs, and Jinja admin on that same worker. Symptoms: shell/nav works, CSS/JS 200, `/api/activity/stream` 200, then **no** `/api/discover/sections` (or admin never finishes). +Default Docker uses **`UVICORN_WORKERS=1`**. A long-lived sync SSE on `/api/activity/stream` used to run through WsgiToAsgi and **starve** Discover, Library APIs, and admin pages on that same worker. Symptoms: shell/nav works, CSS/JS 200, `/api/activity/stream` 200, then **no** `/api/discover/sections` (or admin never finishes). **Fix (current code):** ASGI serves `/api/activity/stream` and `/api/events/stream` natively (non-blocking); Flask WSGI handlers return **503** if reached. The Friends companion only opens activity SSE when the dock is open. Rebuild/restart the app container after pull: @@ -49,7 +49,7 @@ Product toggles live under **Admin → Features** (and setup → Features). Env | ClamAV unreachable | Start `docker compose --profile clamav up -d` or set `CLAMAV_SOCKET` for host clamd — heuristics still apply when scan module is on | | LiveKit “on” but no voice | Flag alone is not enough — need `LIVEKIT_*` + compose `--profile livekit` | | OIDC button missing | `OIDC_ENABLED` stays **off** until env + Integrations toggle | -| Help / Trailers missing on Systems (or Chat / Collections) after a full reload | Older build: `member_bp` never injected Server Settings into the SPA shell, so Jinja treated the flags as off. Current tree bakes `showHelpButton` / `showTrailers` into every `render_member_spa()` page. Restart the app after the Python hotfix. | +| Help / Trailers missing on Systems (or Chat / Collections) after a full reload | Older build: `member_bp` never injected Server Settings into the SPA shell, so the member shell lacked the flags. Current tree bakes `showHelpButton` / `showTrailers` into every `render_member_spa()` page. Restart the app after the Python hotfix. | ## Security headers / uploads @@ -146,4 +146,4 @@ Expected if `SUPPORT_GITHUB_TOKEN` unset (`github_sync=skipped`). Ticket + admin | Password reset email never arrives (SMTP is configured and other mail works) | **Fixed 2026-09-02.** Not an SMTP fault: the reset link was built against `main.reset_password`, an endpoint that does not exist, so `url_for` raised `BuildError` and the send aborted after the token had already been written. Nothing was queued and nothing bounced, which is why SMTP logs looked clean. Invite mail was unaffected — it builds its URL differently. Rebuild/restart; no Reset Themes needed. | | Reset / invite link in an email points at the wrong host | The origin comes from **Admin → Settings → Site URL** when that is set to a real non-loopback address — set it, and every emailed link is stable. With Site URL unset, Oneirodex falls back to the host the browser actually used, but only when that host is private (loopback, `192.168.x.x`, `.local`, `.lan`, a dotless LAN name). A public hostname is refused unless you list it in `TRUSTED_LINK_HOSTS`, because an unauthenticated caller can put anything in a `Host` header and a poisoned reset link leaks a working token. Published on a real domain? Set `TRUSTED_LINK_HOSTS=games.example.com` (comma separated, port optional) — or just set Site URL. | -Deep ops still use Jinja admin pages behind the React top bar. +Deep ops pages now use the shared React admin shell. diff --git a/docs/assets/readme/hero-banner.png b/docs/assets/readme/hero-banner.png index b0a7f2fef..e90e5968c 100644 Binary files a/docs/assets/readme/hero-banner.png and b/docs/assets/readme/hero-banner.png differ diff --git a/docs/assets/readme/poster-admin-art.png b/docs/assets/readme/poster-admin-art.png index d26ea2d91..df0314359 100644 Binary files a/docs/assets/readme/poster-admin-art.png and b/docs/assets/readme/poster-admin-art.png differ diff --git a/docs/assets/readme/poster-admin-discover.png b/docs/assets/readme/poster-admin-discover.png index e837d9c60..ebb3be2e7 100644 Binary files a/docs/assets/readme/poster-admin-discover.png and b/docs/assets/readme/poster-admin-discover.png differ diff --git a/docs/assets/readme/poster-admin-libraries.png b/docs/assets/readme/poster-admin-libraries.png index 81ef5f4e3..84ed7e715 100644 Binary files a/docs/assets/readme/poster-admin-libraries.png and b/docs/assets/readme/poster-admin-libraries.png differ diff --git a/docs/assets/readme/poster-admin-ops.png b/docs/assets/readme/poster-admin-ops.png index fd8cf15f7..ccc8794ae 100644 Binary files a/docs/assets/readme/poster-admin-ops.png and b/docs/assets/readme/poster-admin-ops.png differ diff --git a/docs/assets/readme/poster-admin-settings.png b/docs/assets/readme/poster-admin-settings.png index 2df4775dc..c890f6c02 100644 Binary files a/docs/assets/readme/poster-admin-settings.png and b/docs/assets/readme/poster-admin-settings.png differ diff --git a/docs/assets/readme/poster-admin-users.png b/docs/assets/readme/poster-admin-users.png index b6fe0c29b..d21772d69 100644 Binary files a/docs/assets/readme/poster-admin-users.png and b/docs/assets/readme/poster-admin-users.png differ diff --git a/docs/assets/readme/poster-big-picture.png b/docs/assets/readme/poster-big-picture.png index 1c9f2ab01..854ea9cf5 100644 Binary files a/docs/assets/readme/poster-big-picture.png and b/docs/assets/readme/poster-big-picture.png differ diff --git a/docs/assets/readme/poster-calendar-news.png b/docs/assets/readme/poster-calendar-news.png index 972b51ca3..f574e92e5 100644 Binary files a/docs/assets/readme/poster-calendar-news.png and b/docs/assets/readme/poster-calendar-news.png differ diff --git a/docs/assets/readme/poster-chat-spaces.png b/docs/assets/readme/poster-chat-spaces.png index 38c7e4a40..fc4d3b448 100644 Binary files a/docs/assets/readme/poster-chat-spaces.png and b/docs/assets/readme/poster-chat-spaces.png differ diff --git a/docs/assets/readme/poster-collections.png b/docs/assets/readme/poster-collections.png index 1444fcfdb..f58add657 100644 Binary files a/docs/assets/readme/poster-collections.png and b/docs/assets/readme/poster-collections.png differ diff --git a/docs/assets/readme/poster-command-palette.png b/docs/assets/readme/poster-command-palette.png index b0bc1f82f..f5e705391 100644 Binary files a/docs/assets/readme/poster-command-palette.png and b/docs/assets/readme/poster-command-palette.png differ diff --git a/docs/assets/readme/poster-discover.png b/docs/assets/readme/poster-discover.png index 6652d6895..68c15b2b7 100644 Binary files a/docs/assets/readme/poster-discover.png and b/docs/assets/readme/poster-discover.png differ diff --git a/docs/assets/readme/poster-friends.png b/docs/assets/readme/poster-friends.png index e106beb48..3ad39874a 100644 Binary files a/docs/assets/readme/poster-friends.png and b/docs/assets/readme/poster-friends.png differ diff --git a/docs/assets/readme/poster-game-details.png b/docs/assets/readme/poster-game-details.png index a33fa0707..00f229848 100644 Binary files a/docs/assets/readme/poster-game-details.png and b/docs/assets/readme/poster-game-details.png differ diff --git a/docs/assets/readme/poster-help-support.png b/docs/assets/readme/poster-help-support.png index 9c9eb4514..307d906c0 100644 Binary files a/docs/assets/readme/poster-help-support.png and b/docs/assets/readme/poster-help-support.png differ diff --git a/docs/assets/readme/poster-library.png b/docs/assets/readme/poster-library.png index f461bad8b..1ec7cf0b7 100644 Binary files a/docs/assets/readme/poster-library.png and b/docs/assets/readme/poster-library.png differ diff --git a/docs/assets/readme/poster-play.png b/docs/assets/readme/poster-play.png index 68bc192fb..fc87ae5b0 100644 Binary files a/docs/assets/readme/poster-play.png and b/docs/assets/readme/poster-play.png differ diff --git a/docs/assets/readme/poster-preferences.png b/docs/assets/readme/poster-preferences.png index e638f763a..766424a5a 100644 Binary files a/docs/assets/readme/poster-preferences.png and b/docs/assets/readme/poster-preferences.png differ diff --git a/docs/assets/readme/poster-systems.png b/docs/assets/readme/poster-systems.png index de84bc679..e4ce6e5a1 100644 Binary files a/docs/assets/readme/poster-systems.png and b/docs/assets/readme/poster-systems.png differ diff --git a/docs/assets/readme/poster-tour.png b/docs/assets/readme/poster-tour.png index edf359c65..e61da0f70 100644 Binary files a/docs/assets/readme/poster-tour.png and b/docs/assets/readme/poster-tour.png differ diff --git a/docs/assets/readme/screenshot-admin-libraries.png b/docs/assets/readme/screenshot-admin-libraries.png index 3ff63e9b7..16b77e97f 100644 Binary files a/docs/assets/readme/screenshot-admin-libraries.png and b/docs/assets/readme/screenshot-admin-libraries.png differ diff --git a/docs/assets/readme/screenshot-admin-ops.png b/docs/assets/readme/screenshot-admin-ops.png index bc58a1cea..8b5fda7c6 100644 Binary files a/docs/assets/readme/screenshot-admin-ops.png and b/docs/assets/readme/screenshot-admin-ops.png differ diff --git a/docs/assets/readme/screenshot-art-studio.png b/docs/assets/readme/screenshot-art-studio.png index 9c474be47..b657bc6dc 100644 Binary files a/docs/assets/readme/screenshot-art-studio.png and b/docs/assets/readme/screenshot-art-studio.png differ diff --git a/docs/assets/readme/screenshot-chat.png b/docs/assets/readme/screenshot-chat.png index c66e94dec..045c96890 100644 Binary files a/docs/assets/readme/screenshot-chat.png and b/docs/assets/readme/screenshot-chat.png differ diff --git a/docs/assets/readme/screenshot-discover.png b/docs/assets/readme/screenshot-discover.png index b3d83374d..4f5b82a9f 100644 Binary files a/docs/assets/readme/screenshot-discover.png and b/docs/assets/readme/screenshot-discover.png differ diff --git a/docs/assets/readme/screenshot-filters.png b/docs/assets/readme/screenshot-filters.png index 7f7166a2b..476d57deb 100644 Binary files a/docs/assets/readme/screenshot-filters.png and b/docs/assets/readme/screenshot-filters.png differ diff --git a/docs/assets/readme/screenshot-preferences.png b/docs/assets/readme/screenshot-preferences.png index 0eae675da..4978e05aa 100644 Binary files a/docs/assets/readme/screenshot-preferences.png and b/docs/assets/readme/screenshot-preferences.png differ diff --git a/docs/dev/admin-jinja-inventory.md b/docs/dev/admin-jinja-inventory.md index d02292639..e5b5b1644 100644 --- a/docs/dev/admin-jinja-inventory.md +++ b/docs/dev/admin-jinja-inventory.md @@ -1,96 +1,839 @@ -# Admin Jinja → React inventory (H-D.5) +# Template retirement and runtime migration > **Doc status:** Active -Measured 2026-09-19 on `main` after #150. The admin surface is a *declared* -hybrid: every admin template extends `base_admin.html` and states, through -`{% block admin_render %}`, whether React owns the body (`spa`) or the Jinja -body plus a `static/js` handler does (`auto` / `legacy`). This is the census -of what is still server-rendered, what it depends on, and the order in which -it gets ported. It closes the "progressive hybrid" verdict by making the tail -visible and sized instead of implied. - -## The rule for a port - -A port replaces **the template body and its JS handler together**: the -template flips to `admin_render` = `spa` and stops including the script; the -React page in `frontend/admin-app/src/pages/` talks to the same JSON API the -handler already used. The old handler file is deleted in the same commit — a -stale copy on the themes volume is harmless once no template loads it. Where a -handler talks to a *form* route rather than a JSON API, the API comes first -(see `docs/dev/pydantic-adoption.md`), then the page. Never rename a DOM id a -theme-volume handler still reads in the same release as the handler. - -Each port adds the page to `AdminRoutes.tsx`, a vitest file next to the page, -and keeps the `

    ` equal to the settings card title (that is what -`AdminRoutes.test.tsx` asserts). - -## Census - -48 templates under `templates/admin/` (45 pages + 3 partials). - -**Tier 0 — already React (17).** Six-line shells; nothing to port: -`admin_announcements`, `admin_art_studio`, `admin_dashboard`, `admin_invites`, -`admin_manage_extensions`, `admin_ops`, `admin_remote_play`, -`admin_scan_match_settings`, `admin_settings_shell`, `admin_support`, -`admin_system_danger`, `admin_users`, `quality_profiles`, `storage`, plus -`admin_plugins` (empty `auto` body — React `PluginsPage` renders) and, since -this cycle, `detail_layout` and `emulator_profiles` (the first two ports below). - -**Tier 1 — small panels (first).** One handler of under 80 lines; the API -exists or is one small PR away. Ordered by handler size. - -| Template (lines) | Handler (lines) | API today | React page today | -|---|---|---|---| -| `detail_layout` (24) | `od_admin_detail_layout.js` (58) | `/api/layouts/detail` GET/PUT | **`DetailLayoutPage` — done** | -| `admin_newsletter` (87) + `view_newsletter` (35) | `od_admin_newsletter.js` (24) | form routes only — API first | — | -| `admin_manage_whitelist` (88) | `od_admin_whitelist.js` (38) | form routes + `DELETE /admin/whitelist/` — API first | — | -| `features_settings` (116) | `od_admin_features.js` (39) | form route + `/api/admin/challenge-solver/*` | — | -| `admin_help` (294) | `od_admin_help.js` (58) | static content (no API) | — (HelpPage is member-side) | -| `admin_manage_downloads` (83) | `od_admin_downloads.js` (62) | server-rendered table + `/api/delete_download/` | — | -| `admin_reference_sets` (160) | `od_admin_reference_sets.js` (172) | `/api/reference-sets/rehash`, `/api/reference-sets/repair-preview`, `/api/licensed-catalog/refresh` (+ server-rendered table) | — | -| `emulator_profiles` (32, `legacy`) | `od_admin_emulator_profiles.js` (68) | `/api/emulator-profiles` | **`EmulatorsPage` — done** (the firmware / RA / pilot island moved in with it) | -| `ai_assist` (77) | `od_admin_ai_assist.js` (75) | `/api/ai/config`, `/status`, `/triage`, `/apply-triage`, `/doctor-notes` | route stub | -| `admin_manage_igdb_settings` (33) · `admin_manage_smtp_settings` (40) | `admin_manage_igdb_settings.js` · `admin_manage_smtp_settings.js` (+ `password_visibility.js`) | form routes | `IntegrationsPage` (inventory only) | -| `admin_chat_emoji` (66) · `admin_manage_invites` (53) · `admin_statistics` (68) · `admin_server_logs` (99) · `admin_manage_themes_readme` (90) | small / chart-utils | mixed | `InvitesPage` covers invites | - -**Tier 2 — medium forms and dashboards.** Each is a page of its own. - -| Template (lines) | Handler(s) | Note | -|---|---|---| -| `admin_manage_filters` (145) | `admin_manage_filters.js` | scanning filter patterns | -| `admin_manage_library_create` (154) | `admin_manage_library_create.js` | `LibrariesPage` owns the list; create is still Jinja | -| `admin_manage_themes` (163) | `od_admin_themes.js` (177) | `ThemesPage` is chrome-only today; the picker + Reset Themes button is the body | -| `admin_discovery_sections` (174) | `admin/discovery_sections.js` (theme volume) | drag-order of Discover shelves | -| `admin_server_status` (195) | `admin/server_status.js` + `chart-utils.js` | charts; `OpsPage` has the tiles, not the history charts | -| `attract_mode_settings` (201) | `admin_attract_mode.js` + `od_attract_boot.js` | live preview iframe | -| `arr_module` (216) | `od_admin_arr.js` (386) | GET+PUT, bulk text; validator note in pydantic-adoption | -| `new_server_settings` (272) | `admin_manage_server_settings.js` | the big settings form; route stub exists | -| `admin_game_identify` (277) | `admin_game_identify.js` | add/edit game identify — shared with `routes_games_ext` | -| `integrations` (348) | six handlers incl. `od_admin_integrations_oidc.js` | `IntegrationsPage` shows the inventory; the credential forms are here | - -**Tier 3 — the Libraries & scans hybrid (last).** `admin_manage_scanjobs` -(986) with partials `admin_libraries_panel` (120), `admin_library_tools_body` -(169), `admin_scan_jobs_panel` (123) and handlers `od_admin_scanjobs_inline.js` -(465), `od_admin_library_tools.js` (212), `admin_manage_scanjobs.js`, -`admin_manage_libs.js`, `od_reparent_lib_modals.js`. React already owns the -tab bar, the libraries panel mount (`useLibrariesPanelMount`), the propose and -import leaves and the scan toasts; the panels themselves are still -server-rendered. `admin_manage_libraries` (52) and `admin_library_tools` (11) -are redirect shells onto it. This is the one that pays for the others' pattern. - -## Order - -Tier 1 top to bottom, then Tier 2 by size, Tier 3 last. Each port is its own -PR (`v11/debt-admin-react-`), with the live page read in the browser after -deploy — the `auto` heuristic in `base_admin.html` means a half-flipped -template can blank a route, which only the deployed page shows. - -## Counts - -| | 2026-09-19 | +Entries after the current snapshot are dated migration checkpoints. Their status text describes the system at that checkpoint; later notes may supersede it. + +**Current snapshot, 2026-10-14:** `oneirodex/templates/` contains no HTML files; application Python has no `render_template`, `render_template_string`, `stream_template`, Jinja filter/global registration, context processor, or template adapter. Email/offline documents use explicit escaped renderers; member/admin/styleguide documents use Python shell builders or built React shells. `frontend/shared/icons/manifest.json` is the canonical SVG catalog and only its React adapter is generated. The asset URL helpers have no template adapter. `/pulse`, `/offline`, `/sw.js`, `/vr/sw.js`, `/favicon.ico`, and public static files use native ASGI handlers; service workers resolve only from shipped files, private paths remain denied, and member BIOS retains signed-session authorization. Normal ASGI startup creates framework-independent config and `NativeRuntimeContext`, with no Flask construction or WSGI middleware. Flask and its extensions are now in `requirements-dev.txt`; the Docker runtime installs only `requirements.txt`, which excludes Flask and Jinja. Legacy Flask modules remain for development-time compatibility and parity tests. The latest method/path census finds 730 legacy pairs covered by 716 FastAPI patterns and 14 custom ASGI handlers, with zero unmatched; that inventory does not establish behavior parity. + +**P17 completion, 2026-10-14:** A clean Docker image built the member, auth, admin, admin-pilot and Ops bundles; the runtime image was verified without Flask/Jinja imports and normal ASGI startup registered 159 FastAPI routes through `NativeRuntimeContext`. The focused final migration gate passed 361/361; all 124 native route modules passed 715/715 DB-free cases, and the all-route census reports 730/730 legacy method/path pairs covered. The 6,656-test broad selection was stopped early for runtime and is not a passing result. DB integration tests were not run because `TEST_DATABASE_URL` was unset. The current capture stack has no dedicated screenshot account; older populated UI captures remain useful visual evidence but are not claimed as post-migration browser acceptance. See [P18 acceptance evidence](../_private/evidence/p18-ui-acceptance-2026-10-14.md). + +**Production worker import closure, 2026-10-14:** Final isolated-image review found the optional anticheat, save-path, and free-games workers still imported Flask while loading config. Their config reads now prefer `get_runtime_config()` and lazily fall back only for legacy Flask callers. The no-Flask/Jinja import-boundary and poller lifecycle tests passed 44/44; the adjusted lifecycle test passed 9/9. A production-image probe with Flask/Jinja unavailable passed. Staging initially started pollers because Compose does not forward host `.env` values that are not listed under the app service; its compose override now explicitly sets `ONEIRODEX_ENABLE_BACKGROUND_WORKERS=false`. The app was stopped during correction, then restarted with that gate confirmed in container configuration; no poller startup or fetch appeared in the corrected runtime logs. The staging database is isolated. This closes the worker config import issue, not the remaining security/runtime-parity gates. + +The dated checkpoints below are historical migration evidence. Their interim +statements that Flask startup, WSGI, Jinja packages, or runtime routes remain +open are superseded by the current snapshot and P17 completion above. + +**P17 native startup and dispatch checkpoint, 2026-10-14:** Normal ASGI initialization now uses `NativeRuntimeContext` and a native SQLAlchemy engine without constructing Flask. The admin shell is required in standard runtime; `ONEIRODEX_NATIVE_ADMIN_PILOT` and its Compose rollback path were removed. A focused signed-session/import-boundary/ASGI set passed 44/44. The all-route census reports 730 legacy method/path pairs, 716 FastAPI matches, 14 explicit ASGI handlers, and zero unmatched paths. API-envelope lint, targeted compileall, all three Compose config validations, and `git diff --check` passed. This verifies the current dispatch inventory and selected runtime flows only; Flask-dependent services/extensions and Flask/Jinja package removal remain open, so JIN-07 is active. + +**P17 background worker runtime boundary, 2026-10-14:** `NativeRuntimeContext.app_context()` now binds a request/thread-local SQLAlchemy session to the native engine without using Flask. Background lifecycle registration, Discover ML, store deals, ownership polling, and library watch now resolve persistence through `native_db`, retaining its Flask-SQLAlchemy fallback for legacy callers. Scheduled scan polling passes the native scope into queue drain and worker launch. Subprocess import-block probes verified all nine scheduler registrations and signed-session member/admin ASGI flows with Flask/Jinja unavailable. The non-DB worker/service selection passed 36/36; a follow-up ASGI/session/SSE/download selection passed 28/28. API-envelope lint, targeted compileall, and scoped diff checks passed; the 730/716/14/0 route census remains unchanged. DB-marked tests were deselected because `TEST_DATABASE_URL` is unset. Full DB-backed scan lifecycle and runtime dependency removal remain open; JIN-07 stays active. + +**P17 native config scope follow-up, 2026-10-14:** Native ASGI request and worker scopes now expose the active runtime config with a context-local accessor alongside the native SQLAlchemy session. AI assist, challenge solver, debrid, remote-play, emulator-save, save-crypto, token, custom-emoji, and emulator-cheat helpers no longer eagerly import Flask for configuration reads; a legacy fallback remains outside native scopes. The import-block regression verifies these modules load with Flask and Jinja unavailable. Runtime-context/import-boundary tests passed 11/11; challenge-solver, remote-play, token-health, and remote-play-status checks passed 46/46 in the preceding combined run; API-envelope lint and scoped compile/diff checks passed. This closes a config access slice only; other Flask request, extension, and lifecycle dependencies remain, so strict JIN-07 stays active. + +**P17 framework-free service graph follow-up, 2026-10-14:** Added a native static-asset URL helper and moved Discover tile projection out of the legacy route module so native browse, game-detail, cover, and art helpers can load without Flask. Config-only Flask imports in browser-player, BIOS, email-digest, theme, notification, SMTP, and status utilities are now lazy or native-context aware; store-sync persistence uses the native DB facade. A subprocess import-block census now imports all 172 runtime_native modules and all 130 utility modules that use native_db with Flask and Jinja unavailable: zero import failures. The focused import-boundary, runtime-context, cover URL, game-detail, and Discover serializer batch passed 33/33; browser-player checks passed 7/7; API-envelope lint and compile checks passed. This proves module importability, not full route semantics or complete package-free runtime; remaining legacy request flows and Flask/Jinja dependency retirement keep JIN-07 active. + +**P17 member-flow and runtime cutover, 2026-10-14:** Removed Flask, Flask-WTF, Flask-SQLAlchemy, Flask-Login, and Jinja2 from runtime requirements and pinned them in `requirements-dev.txt` for legacy route tests. Native startup and session/import-boundary checks confirm normal ASGI flows work with Flask/Jinja imports blocked. The full native browse/session suite passed 310/310; native dispatcher passed 34/34; expanded framework-boundary, account-preferences, Arr indexers/hardlinks, Discover serialization, and dispatcher selection passed 78/78; RBAC/Arr regressions passed 23/23; runtime import-boundary tests passed 9/9. The path census reports 730 legacy pairs, 716 FastAPI matches, 14 custom ASGI matches, and zero unmatched. DB-backed tests were not run because `TEST_DATABASE_URL` is unset; behavior was exercised through isolated native adapters and in-memory test databases. Container rebuild verification remains separate. + +**P17 native member shell slice, 2026-10-14:** Selected authenticated member React-shell pages now have FastAPI GET/HEAD handlers, preserving the signed browser session, CSRF bootstrap, one-time flash rendering, security headers, theme/icon assets, and feature-flag redirects. Asset URL, icon-theme, library-path, and avatar helpers can be imported and called without loading Flask/Jinja. Focused shell/import/renderer/theme/icon/cover tests passed 46/46; API-envelope lint passed; the route census remains 529/529 API method/path pairs with zero unmatched. This is a member page dispatch slice only: `LazyASGIApp` still initializes Flask before it reaches this router, and the force-refresh worker still calls Flask-bound scan orchestration. No visual capture was needed because the generated shell markup and assets are intended to match the existing page output. + +**P17 member feature-flag parity follow-up, 2026-10-14:** The native `/activity` and `/vr` gates now parse string-valued settings using the legacy route's accepted truthy values, so a string value of false remains disabled. Focused native member-shell tests passed 3/3, including string-valued disabled flags; compileall passed. Flask startup, WSGI, remaining route parity, worker ownership, and dependency retirement remain open. + +**P17 cache and settings runtime extraction, 2026-10-14:** Replaced the SimpleCache-only Flask-Caching extension with a shared thread-safe process cache that preserves the 300-second default TTL, explicit timeouts, pickle-copy semantics, 500-entry threshold, and `get`/`set`/`delete`/`clear` operations. Native settings/filter routes and shared GlobalSettings/scan-policy helpers now use the native SQLAlchemy session facade and framework-free cache module. `flask_caching` is no longer imported or pinned. The import-boundary check also creates a GlobalSettings row through an in-memory SQLite engine scope while asserting Flask/Jinja remain unloaded. Focused cache/import-boundary and native settings/filter/policy tests passed 27/27; compileall and API-envelope lint passed. Flask and Jinja remain required by the broader legacy app and are not retired by this extension change. + +This is not strict zero-Jinja runtime completion: the primary app still imports Flask, which brings Jinja2 transitively, and some FastAPI routes still use Flask request/session/security compatibility hooks. The approved direction remains FastAPI with existing-session compatibility. JIN-07 stays active until the primary runtime is replaced and route/session/CSRF parity passes. Existing evidence below is historical; it records the migration sequence and is not a current source census. + +**P17 admin theme action cutover, 2026-10-14:** `POST /admin/themes`, `POST /admin/themes/delete/{theme_name}`, and `POST /admin/themes/reset` now dispatch through the mounted FastAPI theme router for compatibility form requests; reset also keeps the React JSON request. The shared reset helper takes explicit package/library paths and audit actor ID, keeps custom themes, and clears asset versions after restoring defaults. The native routes preserve browser CSRF (including form token fields), signed-session admin checks, upload size validation, JSON envelope/status, redirects and flash-cookie behavior; reset errors sent to JSON are generic and do not expose filesystem paths. Focused native/helper/outer-ASGI tests passed 12/12; compileall, API-envelope lint, import-boundary checks, and scoped `git diff --check` passed. No theme files were uploaded, deleted, or reset outside fake-backed temporary tests. P17 remains active: FastAPI lifespan still constructs Flask and mounts WSGI, and Flask transitively requires Jinja2. + +**P17 admin discovery sections API cutover, 2026-10-14:** GET/HEAD and all seven write operations for `/admin/api/discovery_sections` now dispatch through FastAPI: create, update, schedule, pin, delete, reorder, and visibility. The compatibility adapter retains signed-session admin and token-scope checks, browser CSRF, response envelopes/statuses, no-store/HEAD behavior, actor audit records, and rollback on unexpected failures; failure audit messages omit request and database details. Fake-backed native route, outer-ASGI dispatch, and import-boundary selection passed 7/7; compileall passed. No discovery shelves were modified in persistent storage. Strict JIN-07 remains active for other legacy routes, Flask lifespan/WSGI retirement, and dependency removal. + +**P17 admin invites API cutover, 2026-10-14:** `GET`/`HEAD /admin/api/invites` and `POST /admin/api/invites/quota` now dispatch through FastAPI. The roster keeps its raw JSON shape, while quota adjustment preserves the shared success/error envelope, strict UUID/integer bounds, admin/token scope, browser CSRF and row locking. The fake-backed invite routes, discovery routes, outer-ASGI dispatch, and runtime import-boundary selection passed 10/10; compileall, API-envelope lint and scoped diff checks passed. `TEST_DATABASE_URL` is unset, so legacy DB-backed invite integration was not accepted as parity evidence. No invite quotas were changed in persistent storage. Strict JIN-07 remains active for all remaining routes and Flask lifespan/WSGI/dependency retirement. + +**P17 admin user read cutover, 2026-10-14:** `GET`/`HEAD /admin/api/users` and `/admin/api/user/` now dispatch through FastAPI and preserve the raw roster/detail shapes, placeholder-email privacy fields, library/filter projections, signed-session admin check, no-store and HEAD behavior. The fake-backed route, invite/discovery routes, outer-ASGI dispatch, redirect, and import-boundary checks passed 16/16; compileall, API-envelope lint and scoped diff checks passed. This uncovered and fixed missing default arguments in the shared native login redirect helper: unauthenticated calls now use Flask-Login's configured message/category and retain the `next` target. User create/update/delete remain on the compatibility route; DB-backed CRUD parity and all remaining Flask/runtime retirement gates remain open under strict JIN-07. + +**P17 admin user creation cutover, 2026-10-14:** `PUT /admin/api/user/0` now dispatches through FastAPI. The new adapter shares the existing framework-independent username/email/role/about validators with the legacy route and preserves duplicate checks, password hashing, optional-email placeholder behavior, forced-unverified state for emailless accounts, audit actor, and success/error envelope. Direct route/data-adapter checks and outer-ASGI dispatch passed 10/10 with the neighboring user read/import-boundary checks; compileall and API-envelope lint passed. `TEST_DATABASE_URL` is unset, so the database-backed legacy account suite was not run as acceptance evidence. User update/delete and Flask lifespan/WSGI/dependency retirement remain active JIN-07 work. + +**P17 Operations admin actions, 2026-10-14:** `DELETE /admin/api/system_logs/clear` and `POST /admin/clear_permission_errors` now dispatch through FastAPI. The log-clear action retains session/token admin authorization, CSRF, delete count response, post-clear audit event, and safe failure response; no persistent logs were cleared by tests. Permission-error dismissal clears the three legacy session keys and re-signs the compatible session cookie. Fake-backed action, session-cookie, outer-ASGI, and import-boundary checks passed 7/7; compileall, API-envelope lint, and scoped diff checks passed. Strict JIN-07 remains active for remaining route behavior, Flask lifespan/WSGI retirement, and package removal. + +**P17 admin whitelist API cutover, 2026-10-14:** Admin whitelist GET/HEAD, POST add, and DELETE entry routes now dispatch through FastAPI. The adapter preserves the `{entries, ...success}` envelope, normalized/validated email, duplicate conflict response, create status 201, not-found delete behavior, audit records, signed-session admin/token checks, and browser CSRF. Fake-backed whitelist/invites/user/discovery/Operations/import-boundary checks passed 19/19. A dedicated outer-ASGI request now also exercises whitelist GET/POST/DELETE; the direct whitelist and outer dispatch selection passed 4/4. Compileall, API-envelope lint, and scoped diff checks passed. No whitelist rows were changed in persistent storage. Strict JIN-07 remains active for the remaining API/page methods and Flask lifespan/WSGI/dependency retirement. + +**Runtime census snapshot, 2026-10-14:** registering the source blueprints on an in-memory Flask object (without creating an app context or connecting to a database) yields 653 Flask URL rules across 730 methods, including 442 `/api/` and 107 `/admin` rules. The Python AST census finds 217 Flask-importing modules and 25 `FlaskForm` subclasses across 654 first-party Python files under `oneirodex/`. There are zero HTML files, Jinja render calls, or explicit Jinja imports. The latest P17 action cuts and member auth/setup router consolidation are reflected in source, but API counts below are historical checkpoints and do not prove method-gate or behavior parity. The `runtime_native` package now contains no direct Flask/Jinja imports; lifespan and uncaptured route execution still construct Flask and mount WSGI, so strict zero-Jinja is not complete. + +**P17 manual-identify editor mutations, 2026-10-14:** `POST /api/admin/manual-identify` and `PUT /api/admin/manual-identify/{uuid}` now dispatch through FastAPI. The routes retain signed-session admin and token-scope checks, browser CSRF, input/library/path validation, duplicate IGDB checks, local metadata writes, unmatched-folder cleanup, audit events, and refresh scheduling. Folder-size and NFO helpers accept explicit allowed roots for native requests. Focused native/outer-ASGI checks passed 6/6; explicit-root folder-size/NFO checks passed 6/6; sidecar-write tests passed 3/3 with one platform-specific skip; symlink-safety checks from the preceding slice passed 5/5 with 31 host/platform skips. Compileall, API-envelope lint, and `git diff --check` passed. The mutation DB integration suite was not run because `TEST_DATABASE_URL` is unset. Image refresh still uses the transitional Flask-context scheduler; strict JIN-07 remains active for remaining route parity, primary Flask startup, WSGI fallback, and dependency retirement. + +**P17 integrated native regression, 2026-10-14:** `tests/test_asgi_native_browse.py`, `tests/test_runtime_import_boundary.py`, and `tests/test_native_manual_identify_mutations.py` passed 293/293 in 133.10 seconds. Three SQLite `ResourceWarning`s about unclosed connections were emitted; no assertions failed. The suite remains DB-free and does not establish legacy persistence parity. JIN-07 remains active. + +**P17 library-tools context reduction, 2026-10-14:** library inspection and manual-identify preview adapters now use the existing request-scoped native SQLAlchemy session and explicit app configuration without pushing Flask app contexts for root resolution, scan-mode selection, or candidate enumeration. Missing-folder enumeration no longer attempts to flash outside a Flask request. The focused helper/route selection passed 25/25, and compileall, API-envelope lint, and `git diff --check` passed. The separate library-tools mutations and primary Flask/WSGI/dependency retirement remain active under JIN-07. + +**P17 unmatched projection boundary, 2026-10-14:** unmatched-folder row and comparison projections now live in `oneirodex.utils.scan_unmatched_rows`, shared by the Flask route modules and FastAPI adapters. Native routes no longer load that code by a dynamic file path from `routes_apis`. A compatibility re-export remains for older imports. Focused fake-backed unmatched read, mutation, feedback, and import-boundary suites passed 20/20; compileall, API-envelope lint, and `git diff --check` passed. The follow-up entries below reduce app-context use in native read/feedback paths; mutation-specific legacy service contexts and the primary Flask/WSGI runtime remain. + +**P17 unmatched read context reduction, 2026-10-14:** cover URL helpers now accept explicit app configuration for static URL and library-path resolution. Native unmatched list/export and duplicate-comparison reads use that app configuration with the request-scoped database session, so they no longer push Flask app contexts. The combined cover URL, unmatched read/mutation/feedback, and import-boundary selection passed 35/35; compileall, API-envelope lint, and `git diff --check` passed. Other remaining app-context call sites and the primary Flask/WSGI/dependency retirement still block JIN-07 completion. + +**P17 native context reductions, 2026-10-14:** unmatched feedback routes now run auth/CSRF dispatch without a Flask app context; audit helpers receive explicit actor IDs. Library-tools game/library lookups use the scoped native SQLAlchemy session directly. Focused library-tools, unmatched read/mutation/feedback, cover URL, and import-boundary checks passed 47/47. Compileall and API-envelope lint passed. Mutation-specific legacy scan/identify service contexts, primary Flask startup, WSGI, and Flask/Jinja dependencies remain active JIN-07 work. + +**P17 library mutation context reduction, 2026-10-14:** library-tools link-existing-game, rename, and library-doctor apply operations now use the native request session without Flask app-context pushes. Link audit records now include the authenticated actor ID. The focused library-tools reads, preview, and mutation suites passed 29/29. Provider-backed metadata enrichment and freshness still use Flask app contexts; broader route and runtime gates remain open. + +**P17 game-mod adapter context reduction, 2026-10-14:** FastAPI game-mod routes now pass Flask settings through a request-local config scope, and native ACL reads query with SQLAlchemy `select()` under the already-active native request session. The adapter no longer pushes Flask app contexts for the game-mod data operations; legacy routes retain their `current_app` defaults. The combined native game-mod and mocked provider-catalog selection passed 18/18 before adding the no-Flask-context config regression; the updated native game-mod suite passed 7/7. Provider HTTP calls were stubbed. Native adapter source now has 34 explicit app-context call sites; Flask startup/WSGI fallback and Flask/Jinja dependencies remain active, so JIN-07 stays open. + +**P17 IGDB name-search API cutover, 2026-10-14:** Authenticated `GET`/`HEAD /api/search_igdb_by_name` now dispatch through the existing FastAPI IGDB router. It preserves first-value query parsing, the optional numeric platform filter, the 10-result IGDB search, `{results: ...}` success payload, bad-request and bad-gateway envelopes, signed-session login redirect, no-store/security headers, and empty HEAD bodies. Native route and outer-ASGI checks passed 12/12; provider requests were stubbed. Compileall, API-envelope lint, and scoped diff checks passed. `TEST_DATABASE_URL` was unset, so the DB-backed legacy IGDB suite was not run. The current route registration inventory has 698 Flask API method/path pairs and 699 FastAPI OpenAPI operations; a converter-aware sample matcher covers 665 legacy pairs, while remaining results include custom outer-ASGI handlers and require behavior-level review. This census is not final parity evidence; JIN-07 remains active for unmatched paths and Flask/WSGI/dependency retirement. + +**P17 storage hardlink API cutover, 2026-10-14:** `POST /api/storage/hardlink/preview` and `/api/storage/hardlink/apply` now dispatch through FastAPI. The native adapter preserves admin and token-scope checks, browser-session CSRF, API-token behavior, Pydantic body validation before feature-flag checks, the helper/apply gates, configured library-root path safety, raw preview `ok` semantics, 201 apply response, audit attempt, login redirect, and security headers. Focused outer-ASGI tests passed for preview, unsafe paths, missing CSRF, feature-disabled behavior, and apply/audit using a fake adapter; no filesystem hardlink was made. The combined DB-free native browse and storage adapter suites passed 287/287 after the P17 action cutovers. The DB-backed storage suites were not run because `TEST_DATABASE_URL` is unset. JIN-07 remains active for the remaining legacy routes and Flask/WSGI/dependency retirement. + +**P17 admin malware-scan action cutover, 2026-10-14:** `POST /api/admin/malware-scan` now dispatches through FastAPI while retaining signed-session admin and admin-token scope checks, browser-session CSRF, API-token behavior, request validation, configured games/upload/server-root allowlisting, explicit scan and ClamAV runtime configuration, `scanned_by`, and the 200 clean/skipped versus 409 finding contract. Focused outer-ASGI checks used a fake scanner and proved out-of-root paths and missing CSRF never invoke it; a DB/network-free regression verifies explicit enabled/block flags reach the scan helper. No real scan or file read was initiated. The status read was already native. JIN-07 remains active for remaining API/page parity and Flask/WSGI/dependency retirement. + +**P17 free-game claim and library image preview cutover, 2026-10-14:** `POST /api/news/free-games/{offer_id}/claim-assist` and `POST /api/library/preview-cropped-image` now dispatch through FastAPI. Claim-assist keeps the enabled flag, signed-session identity, CSRF gate, not-found/bad-request results, success envelope, and the ownership helper behind the database adapter. Cropped-image preview keeps admin authorization, CSRF, Pydantic 422 validation, in-memory base64/PIL processing, and the preview response. Focused outer-ASGI checks use a fake claim adapter and image processor; no store ownership changes or file writes occurred. Remember-cookie session renewal and security headers are retained. Strict JIN-07 remains active for remaining legacy routes and Flask/WSGI/dependency retirement. + +**P17 RTC token action cutover, 2026-10-14:** `POST /api/rtc/token` now dispatches through FastAPI and preserves enabled/configured checks, room normalization and membership ACL, spectator publishing permission, child video/screenshare restrictions, configured token minting, session-cookie renewal, and security headers. Fake-backed outer-ASGI checks passed for allowed/denied rooms, CSRF, and child restrictions; no real LiveKit credential or token was used. Strict JIN-07 remains active for remaining API/page parity and primary Flask/WSGI/dependency retirement. + +**P17 native write authentication parity review, 2026-10-14:** The new hardlink, malware-scan, free-game claim, and RTC handlers resolve the request identity first, enforce CSRF for browser-session writes, and retain existing bearer-token handling. The focused auth/action selection passed 8/8 after this adjustment. JIN-07 remains active pending broader route and runtime parity. + +**P17 Server Settings boundary, 2026-10-14:** shared field validation, bounds, defaults, and credential-safe settings projection now live in `oneirodex/utils/server_settings.py`; the legacy Flask API delegates to these helpers, and the native FastAPI adapter no longer imports `routes_admin_ext.settings`. The adapter still persists through the compatibility SQLAlchemy extension inside the native request-local session scope. Focused server-settings, import-boundary, and ASGI dispatch checks passed 41/41. This removes route-module coupling, not the remaining Flask database/runtime dependency. + +**P17 native projection/import follow-up, 2026-10-14:** unmatched-folder region/language fields and update-inbox DLC/local-pack projections now use route-independent utilities; the library inventory’s platform release totals use a shared projection as well. Native unmatched-feedback, update-read, and library-read adapters no longer import their corresponding Flask route modules. The import-boundary assertion now covers those adapters and the focused ROM-language, update projection, import-boundary, and ASGI dispatch selection passed 16/16. Direct Flask route imports remain in four native adapters (admin themes, game assists, library deletion, and VR data); model and SQLAlchemy compatibility still prevent strict zero-Flask acceptance. + +**P17 native adapter helper closure, 2026-10-14:** theme ZIP/name validation and game-assist pack storage/overlay projections now live in shared utilities; FastAPI adapters no longer import the legacy themes or assists routes. The extended no-Flask/Jinja import-boundary check and focused theme, assist, and ASGI selections passed 20/20. Only the library-delete worker and VR data adapter still directly import Flask route modules; native persistence and ASGI lifespan/WSGI remain Flask-backed. + +**P17 VR adapter projection follow-up, 2026-10-14:** VR profile/perspective query predicates, compatibility membership, and cover projection are shared by the Flask route and native data adapter through `oneirodex/utils/vr_catalog.py`. `runtime_native.vr_reads` no longer imports `routes_apis.vr`; its import-boundary check passes. The focused no-Flask import/native-dispatch selection passed 3/3, and the VR compatibility/profile database regression passed 9/9 against the isolated review database. Only the library-delete worker remains as a direct legacy route import in a native adapter; the primary Flask lifespan, WSGI bridge, and Flask/Jinja dependency remain open. + +**P17 native library-delete worker boundary, 2026-10-14:** the existing deletion worker and progress dictionary now live in `oneirodex/utils/library_deletion.py`; legacy SSE/poll routes and the FastAPI library mutation adapter share them. FastAPI passes its application object to the background launcher so the worker keeps its own app context. No `runtime_native` module directly imports a Flask route module now. The legacy library-delete integration selection passed 16/16; after fixing the native app handoff, the focused import-boundary/native mutation/outer-ASGI selection passed 4/4. The worker still uses Flask-SQLAlchemy and Flask application context, so this is a route boundary cleanup, not strict runtime retirement. + +**P17 native RBAC import closure, 2026-10-14:** all native adapter imports of `oneirodex.utils.rbac` have been removed. Pure librarian eligibility and wishlist-request checks now come from `role_catalog`; announcement authoring and draft visibility use the request auth context’s `has_scope("admin")`, preserving signed admin access and requiring explicit admin scope for Bearer tokens. The focused route/RBAC/import-boundary selection passed 14/14. The framework-aware Flask decorators remain for legacy routes. + +**P17 pure role catalog and member-shell dispatch, 2026-10-14 (historical; member-pilot details superseded below):** pure role names/normalization/ranking now live in `oneirodex/utils/role_catalog.py`; native adapters use that module instead of Flask-backed RBAC for these checks across 78 modules. The optional member pilot was subsequently removed; the native auth/setup routers now own those page paths without a pilot flag. The earlier combined suite passed 326/326. + +**P17 setup-state projection boundary, 2026-10-14:** native `/api/setup/state` now reads the safe settings projection from `oneirodex/utils/setup_projections.py`; the Flask route delegates to the same helper. Native setup-state logic uses the pure role catalog instead of Flask-backed RBAC, and importing the route adapter/projection leaves `routes_apis.setup` unloaded. Focused setup-state dispatch and import-boundary checks passed 5/5. The projection still reads through the compatibility DB extension, and the primary ASGI lifespan still constructs Flask. + +**P17 social projection import boundary, 2026-10-14:** native social reads now use shared projections in `oneirodex/utils/social_projections.py`; the legacy social routes delegate to those projections. Role normalization moved to `oneirodex/utils/role_catalog.py`, and importing the projection module does not load Flask or `routes_apis.social`. Projection checks pass 2/2; native social read dispatch and import-boundary checks pass 6/6. The integrated ASGI/filter/admin/social/import-boundary regression passes 287/287. The query still uses the compatibility database extension when executed, so this is not a standalone Flask-free database runtime. JIN-07 remains active. + +**P17 framework-neutral filters and feature metadata, 2026-10-14:** native filter data now uses shared query helpers in `oneirodex/utils/filter_catalog.py`, and the native admin feature read uses `oneirodex/utils/feature_catalog.py`; legacy routes delegate to those same utilities. The native adapters no longer import `routes_apis.filters` or `routes_admin_ext.features`. The focused filter, admin feature, ASGI dispatch, and import-boundary selection passed 17/17. Compileall, API-envelope lint, and scoped diff checks passed. Flask lifespan, WSGI compatibility, other route dependencies, and transitive Flask/Jinja dependencies still block strict JIN-07 completion. + +**P17 setup finalizer decoupling, 2026-10-14:** the native FastAPI setup finalizer now calls library/font/core asset seeders with explicit configuration and background-task owner arguments. It no longer pushes a Flask app context; legacy Flask setup callers retain their context-based defaults. The focused finalizer/boot-asset tests passed 13/13, setup transition CSRF/order/admin checks passed 2/2, Python compileall and API-envelope lint passed, and scoped `git diff --check` passed. Flask app construction, remaining route families, and Flask/Jinja dependency retirement still block strict JIN-07 completion. + +**P17 classic setup form cutover, 2026-10-14:** FastAPI now owns `POST /setup/submit` and form submissions for `/setup/smtp`, `/setup/features`, `/setup/igdb`, `/setup/integrations`, and `/setup/library`. The adapter translates legacy field names to the existing validated transition body, validates Flask-compatible form CSRF tokens before auth, enforces current-step/admin checks, and preserves redirects, flash categories, and the signed first-admin session cookie. Six setup transition/form checks passed before the final session assertion was added; the focused form suite passed 3/3 afterward. The integrated DB-free ASGI dispatcher suite passed 273/273, and compileall, API-envelope lint, and scoped `git diff --check` passed. No setup account, settings, library, or scan data was changed in tests. Remaining Flask routes, lifespan/WSGI ownership, and dependency retirement keep JIN-07 active. + +**P17 member auth form cutover, 2026-10-14:** FastAPI now owns the classic `POST /login`, `/register`, `/reset_password_request`, and `/reset_password/{token}` form routes. The handlers reuse the native auth, registration, and password-reset adapters and preserve form-field CSRF, account gates, redirect/flash behavior, remember-cookie updates, invite-session clearing, reset throttling, token expiry, and non-enumerating reset notices. Three focused adapter-backed form checks passed; the integrated DB-free ASGI dispatcher suite passed 276/276. Python compileall, API-envelope lint, and scoped `git diff --check` passed. No account, credential, invite, or email operation reached external systems during the fake-backed tests. Other Flask routes, lifespan/WSGI ownership, and framework dependency retirement remain open. + +**P17 Operations request-scope follow-up, 2026-10-14:** FastAPI `/admin/api/ops/*` and `/admin/api/library/health` data adapters no longer wrap reads in an extra Flask app context. The existing native request scope supplies ORM access; Ops snapshot helpers receive config, engine, and theme root explicitly, while legacy callers keep their default behavior. Four focused adapter/outer-ASGI checks, the Ops summary suite (15/15), and the integrated DB-free ASGI dispatcher suite (270/270) passed, along with Python compileall, API-envelope lint, and scoped `git diff --check`. This removes a context boundary from native Operations reads; Flask startup/WSGI, unported routes, and dependency retirement remain open. + +The local-only [template/output/action matrix](../_private/coordination/ui-jinja-template-matrix-2026-10-09.md) records the 2026-10-09 pre-migration classification of 100 template files and AST render call sites. It is a historical source review, not current runtime or visual acceptance. + +## Historical baseline (2026-10-09) + +| Measure | 2026-10-09 source census | Meaning | +|---|---:|---| +| HTML files under `oneirodex/templates/` | 100 | Includes base shells, shared partials and email output. | +| Files under `templates/admin/` | 50 | 47 non-partial page files and 3 partials. | +| Admin page files with direct literal render references | 43 | Four other page files currently have retired/redirecting routes or no direct reference; see private matrix. | +| Admin pages explicitly setting `admin_render='spa'` | 18 | The other 29 non-partial files omit the block; React/Jinja ownership must be resolved per route, not inferred from this count alone. | +| Admin templates with no explicit `admin_render` block | 32 | Includes the 3 partials; most page files fall through to `base_admin.html`'s default `auto`. | +| `render_template` / `render_template_string` call sites | 112 | AST call sites across route and service modules; not a unique-route count. | +| Unique literal template names / names resolving directly | 72 / 71 | One name starts `/login/user_invites.html` while the file is `login/user_invites.html`; runtime loader behavior was not tested. | + +The 2026-09-19 figures (48 admin files, 45 pages + 3 partials, 17 React shells, 28 server-rendered pages + 3 partials, ~5,000 lines, and 33 loaded handler scripts) are stale and must not size the remaining migration. + +**2026-10-13 final template-retirement slice:** removed the remaining app-authored templates, generated Jinja icon adapter, asset-filter compatibility module, and unused `render_template` imports. Rebased icon, shell, theme, toast, PWA, and legal tests onto the React manifest/shell builders. The focused shell/catalog/theme/member-auth/email suite passed 139/139. The integrations/preferences API package collection initially exposed a stale import from the retired settings helper; the API now uses the existing browser-player capability resolver. The focused API/renderer/catalog batch passed 36/36. CSS-token lint, API-envelope lint, shared icon-adapter generation check, Python compileall and `git diff --check` passed. The local template census reports zero HTML files under `oneirodex/templates/` and zero Python render/Jinja registration references. The subsequent DB-backed settings, preferences, setup, login API and info route slice passed 87/87 against the validated local `oneirodextest` database. FastAPI primary-runtime replacement is still required before JIN-07 can be marked complete. + +**Auth/setup shell regression follow-up, 2026-10-13:** the React auth suite had stale assertions for a CSRF meta tag and did not mock the shared CSRF setter. Updated the test to assert the bootstrap token is passed to `setCsrfToken`, and added a direct IGDB optional-skip check with required credential fields left blank. `AuthApp.test.tsx` passes 6/6. No product behavior changed in this test-only correction. + +**FastAPI session compatibility slice, 2026-10-13:** added a framework-independent codec that matches Flask 3.1's default signed session serializer and Flask-WTF 1.3 CSRF token bytes, including compressed cookies, fallback keys, expiry and invalid-token behavior (9/9 parity tests). The member runtime bootstrap preserves the incoming signed user session, issues the compatible CSRF token, and returns the shared security headers and no-store policy. OIDC display settings use request-local ORM scope. The native bootstrap has since moved from the pilot module into `auth_runtime_bootstrap.py`; Flask startup and other legacy routes remain active. + +**Native admin authentication boundary, 2026-10-13:** `/admin/api/runtime-bootstrap` now runs directly in the opt-in FastAPI pilot. Its framework-independent resolver matches Flask-Login session, remember-cookie and Bearer-token precedence; session-protection behavior; active-user/password-fingerprint checks; token expiry/revocation/hash checks; token-scope authorization; and token last-used persistence. The endpoint keeps the signed session and CSRF cookie format, no-store/Vary/security headers, request ID and scrubbed JSON errors. Review caught a missing local security-header import after the initial run; it was restored and the focused resolver, admin/member pilot, session codec, security-header and ASGI dispatch group passed 143/143. Compileall and `git diff --check` passed. Admin page-shell routes and remaining APIs still use the Flask adapter, so P17 and the strict runtime gate remain open. + +**Native member read APIs, 2026-10-13:** `/api/trailers/filters`, `/api/trailers/random`, and `/browse_games` are dispatched through the same FastAPI app after the outer ASGI layer initializes Flask. The handlers reuse the existing query and payload services inside an app context, resolve Flask-compatible sessions/Bearer credentials through the shared request-auth helper, preserve anonymous login redirects and signed flash cookies, and keep GET/HEAD and security-header behavior. Browse retains Werkzeug typed query parsing and Flask JSON-provider serialization. Integrated route/auth/query coverage passed 147/147; one upstream Starlette/httpx deprecation warning remains. This is a bounded read API slice: the Flask WSGI bridge, CSRF middleware paths and the remaining route tree still block JIN-07 completion. + +**Acquire readiness API follow-up, 2026-10-13:** `GET` and `HEAD /api/acquire/status` now join the same direct FastAPI read API app. It reuses the existing readiness calculation and `api_ok` envelope inside an app context, keeps Flask-compatible auth/session resolution and anonymous login/flash behavior, and has an outer-dispatch assertion that Flask request hooks are bypassed. The integrated auth, read payload, query, redirect and HEAD parity suite passes; the broader Acquire search and write endpoints remain on Flask, so JIN-07 stays active. + +**OIDC readiness read API follow-up, 2026-10-13:** `GET` and `HEAD /api/oidc/status` now run on the same direct FastAPI app. The route preserves the report's bare JSON payload, anonymous and non-admin login/flash responses, and the admin-token scope denial; its data adapter only reads the local readiness report and makes no IdP request. The combined browse/trailer/Acquire/OIDC/auth suite passed 160/160 with one Starlette/httpx deprecation warning. Other API groups and the primary Flask runtime remain on the migration path. + +**Member remote-play status follow-up, 2026-10-13:** `GET` and `HEAD /api/remote-play/status` now use the native FastAPI read API app. The handler reuses `member_remote_play_status()` and returns the same member-safe JSON without exposing operator configuration or credentials; anonymous requests retain Flask-Login redirect/flash behavior. Payload safety, disabled state, HEAD, and login behavior are covered; the integrated browse/trailer/Acquire/OIDC/remote-play/auth group passed 165/165 with one Starlette/httpx deprecation warning. No host or network calls were made. The write/admin config routes and primary Flask runtime remain open. + +**Settings hub badge API follow-up, 2026-10-13:** `GET` and `HEAD /api/settings/module-status` now use the native FastAPI read API app. The route preserves the bare status payload, admin role and token-scope policy, and anonymous/non-admin redirect and flash behavior. Its tests inject a fake status adapter, so they do not query or mutate settings rows. The combined browse/trailer/Acquire/OIDC/remote-play/settings/auth suite passed 171/171 with one Starlette/httpx deprecation warning. Other settings APIs and the primary Flask runtime remain on the migration path. + +**Library health summary follow-up, 2026-10-13:** `GET` and `HEAD /api/health/library` now run through the same FastAPI route app. The handler preserves admin authorization, the bare health-summary JSON, first-value query parsing, invalid-limit fallback, limit cap, UUID trimming and current zero/negative behavior. Anonymous redirects and authenticated forbidden envelopes are covered; an injected adapter keeps tests DB-free. The combined native read-route/auth suite passed 179/179 with one Starlette/httpx deprecation warning. Game-level health detail and all remaining Flask API groups remain on the migration path. + +**Game health detail follow-up, 2026-10-13:** `GET` and `HEAD /api/health/games/{game_uuid}` now use the same FastAPI read app. It keeps the existing lookup → not-found response → library ACL → score order, bare score payload, anonymous redirect and security headers. Fake-adapter tests cover allowed, missing, forbidden and anonymous states without database access; the integrated browse/trailer/Acquire/OIDC/remote-play/settings/health/auth suite passed 185/185 with one Starlette/httpx deprecation warning. Remaining app routes and the Flask/Jinja runtime are still open. + +**Quality profile list API follow-up, 2026-10-13:** `GET` and `HEAD /api/quality-profiles` migrated first to the shared FastAPI read app. The remainder of the profile API has since been migrated as recorded below; P17 remains open for the primary runtime and remaining compatibility routes. + +**Storage, malware and RTC status reads, 2026-10-13:** `GET` and `HEAD /api/storage/status`, `/api/admin/malware-scan/status`, and `/api/rtc/status` now share the FastAPI read app. Storage and malware preserve admin/token-scope guards; RTC remains available to any authenticated member. Bare JSON, role/token denial, redirects/flashes, security headers and HEAD are covered with fake adapters and no filesystem, malware scan, database, LiveKit or network action. The combined native read-route/auth suites passed 198/198 with one Starlette/httpx deprecation warning. Write/apply routes and the primary Flask runtime remain open. + +**Device status reads, 2026-10-13:** `GET` and `HEAD /api/admin/challenge-solver/status` and `/api/admin/ambient-lighting/status` now use the native FastAPI read app. Both preserve admin and API-token scope checks, anonymous/non-admin redirect and flash behavior, legacy first-value `probe` parsing, bare JSON, configured security headers, and HEAD content length. The integrated route group passed 205 tests with one upstream Starlette/httpx deprecation warning; injected adapters mean no device probe or config write ran. Other routes and the primary Flask runtime remain open. + +**AI status read, 2026-10-13:** `GET` and `HEAD /api/ai/status` now dispatch through the native FastAPI read app. The route preserves the admin/token-scope gates and the legacy bare payload, including `error` as status data on HTTP 200. Tests inject a fake status adapter, so they make no Ollama request. The integrated native read-route/auth suite passed 211 tests with one upstream Starlette/httpx deprecation warning. Remaining API routes and the primary Flask runtime remain open. + +**Library platform options read, 2026-10-13:** `GET` and `HEAD /api/admin/library_platforms` now dispatch through the native FastAPI read app. It preserves the `api_ok` envelope, admin role/token-scope guards, anonymous redirect using the `admin2` blueprint login view, non-admin danger flash, and security headers. Tests inject enum-like values and use no database, network or filesystem. The combined native read-route/auth group passed 217 tests with one Starlette/httpx deprecation warning. The primary Flask runtime and remaining routes stay open. + +**Arr status read, 2026-10-13:** `GET` and `HEAD /api/arr/status` now dispatch through the native FastAPI read app. It keeps the `api_ok` envelope, admin and token-scope checks, Arr blueprint login redirect, danger flash, and full status projection. Tests use an injected adapter and make no connector requests. The combined native read-route/auth group passed 223 tests with one Starlette/httpx deprecation warning. Other Arr actions and the primary Flask runtime remain open. + +**Discover read APIs, 2026-10-13:** `GET` and `HEAD /api/discover/sections`, `/api/discover/rows/{identifier}`, `/api/discover/zones/{slug}`, `/api/discover/hubs/genre/{genre:path}`, `/api/discover/surprise` and `/api/discover/pins` now use the native FastAPI read app. They preserve the bare feed payload, `api_ok` success/error envelopes, first-value offset/limit/feed-token and surprise filters, zero-limit behavior, multi-segment genre paths and authenticated-member redirect/flash semantics. Pins `PUT` remains on Flask and is covered by an outer-dispatch regression. Tests inject fake builders; no DB/provider work ran. The combined native read-route/auth group passed 246 tests with one Starlette/httpx deprecation warning. Remaining API routes and the primary Flask runtime remain open. + +**Member account APIs, 2026-10-13:** account summary and invite list `GET`/`HEAD`, invite create `POST`, invite revoke `DELETE`, preferences `GET`/`HEAD`/`PUT`, and fixed stock-avatar selection `POST` now dispatch through the native FastAPI app. Existing `api_ok` fields, invite-secret browser-session gate, signed-session and remember-cookie behavior, quota checks, optional invite email, preference choice lists, Pydantic/WTForms validation, and browser/Bearer identity contract are preserved. Mutations validate Flask-WTF-compatible CSRF tokens; the stock-avatar write keeps its existing validation and transaction behavior. Fake adapters prevent database, filesystem, SMTP, and provider actions. The combined native route/auth group passed 282 tests with one upstream Starlette/httpx deprecation warning. Avatar file upload, password/token operations, other routes and the primary Flask runtime remain open. + +**Member-shell consolidation, 2026-10-14:** removed the optional member-shell pilot and its duplicate Flask request dispatcher. Login, registration, confirmation, password-reset and setup pages use the already-mounted FastAPI auth/setup routers; the shared signed-session bootstrap remains native and keeps the Flask-compatible cookie/CSRF format. Focused ASGI dispatch, authentication/session and import-boundary checks passed 62/62 across two targeted runs; the auth-page selection passed 2/2. The complete `runtime_native` AST scan found no direct Flask/Jinja imports, and no references to the retired pilot flag remain. This retires one compatibility adapter but does not remove the primary Flask runtime, WSGI bridge, or Flask/Jinja dependencies. + +**Shared adapter follow-up, 2026-10-13 (historical):** native admin and member pilots used `oneirodex/runtime_native/flask_compat.py` for Flask request context, configured proxy handling, Flask before/after hooks, exception conversion, signed-cookie/session behavior and security headers. The member pilot was removed during the 2026-10-14 consolidation; admin pilot and primary Flask compatibility dispatch remain. + +**Member and styleguide shell follow-up, 2026-10-13:** `render_member_spa()` and `render_styleguide()` build their documents without `render_template`, preserving theme/icon assets and load order, CSRF metadata, flash/image-refresh behavior, modal layers, era atmosphere, and the styleguide's complete theme-specific specimen. The unused `site/member_spa.html`, `site/styleguide.html`, and `base_empty.html` files were removed. Member renderer/assets/legal checks passed 29/29; authenticated member/library/hub/preferences checks passed 43/43 against the validated local test database, with 9/9 member/library route cases rerun after the final asset-order refinement. The styleguide plus shared shell regression set passed 79/79; after restoring flash and modal behavior, the styleguide access/coverage route tests passed 6/6. Compilation and scoped diff checks passed. The existing page contract was preserved and no fresh screenshot was taken. Flask/Jinja remains the primary runtime for the rest of the app. + +**React-only admin shell follow-up, 2026-10-13:** dashboard, announcements, art studio and invites now use `render_admin_spa()` instead of Jinja wrapper templates. The shared builder retains the route title, admin root contract, theme and icon assets, load order, manifest, CSRF, flashes, modal layers, preferences URL and era atmosphere; the existing login/admin decorators remain on each route. Four route render checks passed, and `tests/test_admin_shell.py` plus `tests/test_template_icons.py` passed 28/28. Compilation and scoped diff checks passed. This migrates four shell boundaries; the remaining React and classic routes still need parity review before broader runtime changes. + +**React-only admin shell follow-up 2, 2026-10-13:** images, ownership, remote play and support now use the same Python renderer. Existing decorators and visible titles remain; the four empty wrapper templates were deleted. Focused authenticated render checks passed 8/8 across the two admin-shell batches; shell and icon suites passed 28/28. Census: 79 templates, 99 literal calls, 58 unique names, no dynamic calls. The React and classic route matrices remain open. + +**React-only admin shell follow-up 3, 2026-10-13:** emulator profiles, quality profiles, detail layout, scan/match, users and system danger now use the same renderer. The admin decorators and danger-zone audit event remain intact; six empty wrapper templates were deleted. Focused authenticated render checks passed 14/14 across the current fourteen-page matrix; shell and icon suites passed 28/28. Census: 73 templates, 93 literal calls, 52 unique names, no dynamic calls. P17 remains active; broader React and classic route parity is not yet accepted. + +**React-only admin shell follow-up 4, 2026-10-13:** extensions, ops, settings hub and storage now use the Python shell. Their data is read through the existing API endpoints; the unused extension list, settings-hub status and storage flag template contexts were removed, while settings deep-link redirects and ops audit logging remain. The remaining four wrapper templates were deleted. The four new authenticated route cases passed; the module-status/storage/template icon checks passed 34/34 (two unrelated cases deselected); the extensions and settings route suites passed 25/25. Census: 69 templates, 89 literal calls, 48 unique names, no dynamic calls. P17 and whole-app parity remain open. + +**React-owned plugins shell follow-up, 2026-10-13:** `/admin/plugins` had an empty Jinja body with implicit React ownership. It now uses the shared Python shell with its existing admin gate and title; the template is removed. The route check passed, and the admin shell/icon suite passed 28/28. Census: 68 templates, 88 literal calls, 47 unique names, no dynamic calls. Remaining pages still require route-by-route parity work. + +**Downloads page shell follow-up, 2026-10-13:** `/admin/manage-downloads` now uses the shared React admin document shell and existing admin API. The API returns newest-first rows, an eight-character game UUID, a basename rather than the prior raw ZIP path, and the same status/time fields; React retains the named table, loading/error retry, and explicit delete confirmation. The admin/login guards and legacy POST delete endpoint remain. Removed the page template, its one-use classic script, and the now-unreferenced DataTables partial. React page tests passed 3/3, template icon/vendor checks passed 43/43, the downloads API suite passed within a 20-pass/4-fail initial run, and the compatibility route rerun passed 8/8 (two unrelated cases deselected). That first run caught an accidentally removed `DownloadRequest` import used by the retained POST endpoint; the import was restored and the rerun passed. Census: 66 templates, 87 calls, 46 unique names, no dynamic names. JIN-07 remains active. + +**Whitelist page shell follow-up, 2026-10-13:** `/admin/whitelist` now returns the shared React shell; its list/status data and React add/delete actions use the existing admin APIs. The legacy form POST remains available and redirects with flashed success/validation messages. Removed the Jinja body, its one-use script and the default-theme stylesheet after reference checks. The React page test passed 2/2, focused auth/list/form route checks passed 10/10, the full route suite passed 21/21, and template icon/vendor checks passed 43/43. Census: 65 templates, 86 literal calls, 45 unique names, no dynamic names. JIN-07 remains active. + +**Scan filters page shell follow-up, 2026-10-13:** `/admin/edit_filters` now uses the shared React manager and existing scan-filter APIs. Legacy form POST add/delete paths remain; invalid adds redirect with flash errors. The old `GET /delete_filter/` now only warns and redirects, so opening a link cannot delete data; the React confirmed action uses authenticated `DELETE /api/admin/scan-filters/`. Removed the Jinja page and its one-use theme script. ScanFiltersPage tests passed 3/3, template icon/vendor checks passed 43/43, and the first combined API/route run passed 31 with one compatibility-method test failure; after restoring POST support, the GET-safety and POST compatibility rerun passed 2/2. Census: 64 templates, 85 calls, 44 unique names, no dynamic names. JIN-07 remains active. + +**Attract Mode shell follow-up, 2026-10-13:** `/admin/attract_mode_settings` now serves the React manager through the shared shell; its API continues to own filter options and persisted settings. The document request no longer queries libraries, genres, themes or game date ranges. Removed the Jinja page and its page-only theme CSS/JS. React page tests passed 3/3; the DB-free API/shell/icon suite passed 60/60 after removing the now-stale Arr template entries from the icon inventory. Census: 51 templates, 72 calls, 31 unique names, no dynamic names. JIN-07 remains active. + +**Arr module shell follow-up, 2026-10-13:** `/admin/arr` now uses the shared Python admin shell, with the existing React page continuing to call the Arr APIs. Removed `arr_module.html`; no indexer or hardlink API behavior changed. `ArrPage.test.tsx` passed 5/5 and the shell contract test passed within the focused shell suite. Census: 52 templates, 73 calls, 32 unique names, no dynamic names. JIN-07 remains active. + +**Themes and Server Settings shell follow-up, 2026-10-13:** `/admin/themes`, `/admin/themes/readme` and `/admin/new_server_settings` now emit the shared Python shell; React pages keep theme inventory/upload/delete/reset/loading-motif and bounded settings behavior through the existing APIs. Theme upload POST compatibility remains, and the page GET no longer initializes its upload directory. Removed both theme page templates and the server settings template. The theme/API/shell focused Python group passed 26/26, Server Settings API/shell/icon checks passed 70/70, and React Themes/Authoring/Server Settings tests passed 10/10. Updated stale tests to assert React-owned theme selection and the 28-field contract rather than reading deleted templates. An initial combined run found one stale endpoint assertion; after updating it to `/preferences#themes`, the theme picker/assets/apply/shell rerun passed 42/42. Census: 53 templates, 74 calls, 33 unique names, no dynamic names. JIN-07 remains active. + +**Newsletter shell follow-up, 2026-10-13:** `/admin/newsletter` and `/admin/newsletter/` now serve the common React shell; history, recipients and detail load from the existing guarded APIs. User-triggered email sending remains available through both the validated API and the legacy POST contract; GET no longer queries newsletter state in the page handler. Removed `admin_newsletter.html` and `view_newsletter.html`. DB-free newsletter API, shell and source wiring checks passed 32/32; `NewsletterPage.test.tsx` passed 6/6. No real email was sent. Census: 56 templates, 77 calls, 36 unique names, no dynamic names. JIN-07 remains active. + +**Features settings shell follow-up, 2026-10-13:** `/admin/features` now serves the existing React settings page from the shared shell. GET no longer initializes or commits an empty global-settings row; the guarded APIs own state reads/writes. Legacy form POST still updates the seven database preferences and redirects with existing flash feedback. Removed `features_settings.html`. Focused API/shell checks passed 30/30 and `FeaturesPage.test.tsx` passed 5/5. Census: 58 templates, 79 calls, 38 unique names, no dynamic names. JIN-07 remains active. + +**Statistics page shell follow-up, 2026-10-13:** `/admin/statistics` now returns the shared React admin shell; the page continues loading the existing `/api/admin/statistics` data. Removed the template-only page body; the separate legacy data endpoint remains compatible. `StatisticsPage.test.tsx` passed 2/2; focused statistics wiring/admin-shell checks passed 5/5. The initial broader wiring run had two unrelated failures in collection-client and newsletter source assertions; recorded for the later bug scrub. Census: 59 templates, 80 calls, 39 unique names, no dynamic names. JIN-07 remains active. + +**Discovery sections shell follow-up, 2026-10-13:** `/admin/discovery_sections` now serves the existing React shelf manager from the shared Python shell; discovery data continues through its guarded API. Removed the Jinja page body, while retaining copied theme JS/CSS as rollback/theme artifacts and their legacy safety guard. The DB-free API route group, admin shell and theme guard tests passed 35/35; React discovery page/style tests passed 10/10 within the 15-test batch. Census: 60 templates, 81 calls, 40 unique names, no dynamic names. JIN-07 remains active. + +**Reference sets shell follow-up, 2026-10-13:** `/admin/reference_sets` now emits the shared React admin shell; the existing page fetches its state through `/api/admin/reference-sets`. The legacy form POST still redirects with its existing flash feedback. Removed the page-only Jinja body. The DB-free route/API suite passed 13/13 and `ReferenceSetsPage.test.tsx` passed 5/5. Census: 61 templates, 82 calls, 41 unique names, no dynamic names. JIN-07 remains active. + +**Invite roster shell follow-up, 2026-10-13:** `/admin/manage_invites` now serves the shared React shell, as does its `/admin/invites` canonical path; roster and unused-token counts load from the existing guarded API. The classic form POST still adjusts quota and renders shell flashes. Removed the page body and its route-level roster queries. Existing route tests now assert the shell and exercise roster counts through the API, but the DB-backed route suite could not run because `TEST_DATABASE_URL` is unset. Current census: 62 templates, 83 calls, 42 unique names, no dynamic names. JIN-07 remains active. + +**IGDB settings page shell follow-up, 2026-10-13:** `/admin/igdb_settings` now returns the shared React shell; the screen continues using the no-store settings API, secret-presence-only state, blank-secret retention, and explicit saved-credential test. The legacy JSON POST save route remains for existing clients, and shared integration form/assets are retained because `/admin/integrations` still uses them. Removed the page-only Jinja template. The DB-free API boundary suite passed 13/13 and `IgdbSettingsPage.test.tsx` passed 3/3; database-backed legacy route tests were not run because `TEST_DATABASE_URL` is unset. Census: 63 templates, 84 calls, 43 unique names, no dynamic names. JIN-07 remains active. + +**P17 integration, provider-search, patch-catalog, download queue, and manual-identify reads, 2026-10-14:** OIDC and community integration GET/PUT routes now run through FastAPI with the existing signed-session/admin-token policy, CSRF-before-body validation, secret-presence-only OIDC projection, blank-secret retention, HTTPS issuer validation, community URL checks, no-store responses, and bounded JSON bodies. Ownership diagnostics GET/HEAD and artwork-provider searches GET/HEAD are also mounted natively; provider calls stay behind adapters, and provider searches require admin except the legacy PCGamingWiki authenticated-member path. Patch-catalog search GET/HEAD and guide attachment POST preserve the module gate, game ACL, response shapes, and CSRF checks. Member/admin download queue reads and admin request deletion now use native session routes; responses expose basenames only, and deletion remains CSRF-protected. The remaining library-tools write routes (link, rename apply, doctor apply, Steam metadata backfill, freshness check), plus manual-identify options and editor-state GET/HEAD, are mounted with adapter boundaries. Focused native module tests passed 37/37 across these slices (one upstream Starlette/httpx deprecation warning); seven outer-ASGI dispatch checks passed. Compileall, API-envelope lint, and scoped diff checks passed. Tests used fake adapters; no real provider calls, scans, database mutations, or filesystem changes ran. Artwork application, download request creation/version cleanup, ownership sync mutations, other API families, and Flask/WSGI startup retirement remain open. JIN-07 stays active. The authorized catalog/font payloads still include `catalog_path` and `fonts_dir`, respectively; retain those as explicit security-review follow-ups rather than silently changing compatibility here. + + +**P17 RetroAchievements settings follow-up, 2026-10-14:** Admin integration settings GET/PUT, admin status GET/HEAD, and member username GET/PUT now dispatch through FastAPI. The adapter keeps provider credentials write-only, preserves environment/database source summaries, validates account handles, and scopes member preferences to the authenticated user. Writes validate CSRF before parsing bounded JSON; no provider requests run for these routes. Focused native tests passed 4/4, and the outer-ASGI test passed for both settings read and write. Compileall and API-envelope lint passed. The achievement progress and match routes still require migration, and the primary Flask runtime remains open. + + +**P17 RetroAchievements match and game progress, 2026-10-14:** `POST /api/retroachievements/match` and `GET`/`HEAD /api/games/{game_uuid}/achievements` now run through FastAPI. Match keeps the admin and token-scope gate, validates CSRF before bounded JSON parsing, checks configuration and supported platforms before invoking the injected scan/provider adapter, and scrubs unexpected upstream errors. Achievement reads preserve the game ACL, member handle and progress projection, and `unlocks_here: false`; progress lookup is adapter-backed and was not called by tests where achievements were disabled. Focused tests passed 6/6, with separate outer-ASGI checks for the match action and game read. Compileall, API-envelope lint, and scoped diff checks passed. No real library scan or external provider call ran. Strict JIN-07 remains active for the outstanding route families and primary runtime retirement. + + +**P17 administrator ownership retry/cancel, 2026-10-14:** `POST /api/admin/ownership/connections/{user_id}/{store}/sync` and `/cancel` now use native FastAPI dispatch. CSRF is validated before auth/provider work; the routes preserve admin role and token-scope checks, member/store existence checks, disabled-sync and not-connected errors, job response envelopes, and 202 cancellation status. Provider/job operations stay behind the adapter and the outer-ASGI check used a fake cancellation result. The ownership adapter suite plus outer dispatch passed 6/6; compileall, API-envelope lint, and scoped diff check passed. No real member sync was triggered. P17 remains open for other legacy API families and primary runtime retirement. + + +**P17 admin policy, launcher-import catalogue, and download actions, 2026-10-14:** FastAPI now handles remote-play and scan-match admin config GET/HEAD/PUT, authenticated launcher import-format discovery, download request creation, and orphan-version cleanup. The config routes preserve admin/token-scope policy and connector validation, return no-store headers, and keep secret fields masked by the existing projection. Download actions retain CSRF, `write:download` or librarian/admin gates, game ACL, missing-file `410` semantics, safe storage-root checks, stream URLs, and the orphan cleanup result shape. Direct fake-adapter tests passed 8/8 across these route families; outer-ASGI checks passed for config write, launcher discovery, and both download actions. Compileall, API-envelope lint, and scoped diff checks passed. Provider/scan/filesystem/database effects were not executed by tests. Launcher upload imports, artwork apply, ownership sync families, support writes, and primary Flask/WSGI retirement remain open. + + +**P17 support ticket mutations, 2026-10-14:** `POST /api/support/tickets` and `POST /api/support/tickets/{ticket_id}/resolve` now dispatch through FastAPI. Ticket creation retains signed-session-only access, rate limiting, schema validation, title/body/area/kind/severity handling, GitHub sync status, and admin notification behavior. Resolve retains its admin gate, timestamp/actor fields, and not-found response. Persistence, GitHub calls and notification delivery are isolated in the adapter; tests inject fake results. Direct tests passed 2/2 and outer-ASGI create/resolve dispatch passed. Compileall, API-envelope lint and scoped diff checks passed. No GitHub issue or notification was sent. Strict JIN-07 remains active for imports, remaining ownership/download/admin mutations and Flask/WSGI runtime retirement. + + +## Current admin shape + +The explicit React shell pages include `admin_announcements`, `admin_art_studio`, `admin_dashboard`, `admin_images`, `admin_invites`, `admin_manage_extensions`, `admin_ops`, `admin_ownership`, `admin_remote_play`, `admin_scan_match_settings`, `admin_settings_shell`, `admin_support`, `admin_system_danger`, `admin_users`, `detail_layout`, `emulator_profiles`, `quality_profiles`, and `storage`. Their routes now emit the shared Python admin shell in the standard runtime. Selected routes also have native runtime dispatch (see progress below); the census counts retained source files and does not establish package removal. + +The dated 2026-10-09 classification of direct page bodies is historical. Current React shell migrations are listed in the dated follow-up sections. Scan management and integrations still have Flask route/compatibility handlers, but their document bodies are Python-built React shells; no authored Jinja page body remains. `admin_plugins` also emits the shared shell, and its React page owns the visible content. The Downloads, OIDC, and Release filters workflows additionally have optional native pilot routes. Confirm actual route mode in browser before changing any remaining compatibility path. + +The four retired admin pages (`admin_help`, `admin_library_tools`, `admin_server_logs`, `admin_server_status`) were removed in the 2026-10-13 JIN-07 cleanup after confirming their routes redirect to canonical Help, Libraries & scans, or Ops destinations, and removing page-only assets with no remaining source references. Redirect behavior is covered by focused tests; copied theme assets already installed on an existing library volume are not changed by this source cleanup. + +The Features route (`/admin/features`) now surfaces the existing ambient-lighting config API through the shared React shell. Hyperion/Home Assistant credentials remain write-only; config saves do not probe a connector, and the separate Test action requires confirmation because it can turn lights or a scene on. Flask still owns route dispatch while the API migration continues. + +**Historical Libraries & scans acceptance record:** the large classic `admin_manage_scanjobs.html` body and its three UI partials are now retired. `/scan_management`, `/scan_manual_folder`, and `/libraries` return the shared shell; React routes dispatch `active_tab` links to controls, jobs, unmatched review, scan filters, extensions, image queue and library tools. Existing POST compatibility handlers and API contracts remain. The current standard route now queries page content through guarded APIs. The previously populated-local evidence remains useful for route behavior and responsive states, but predates this shell change; P18 still needs a current-source route review/capture and direct POST compatibility verification. The old scan CSS/JS assets remain in the theme tree pending final dead-asset/copy audit. + +## Port acceptance + +For each route, inventory the rendered template and every workflow/action before replacing the body. Preserve login/admin authorization, CSRF, request validation, form errors, redirects and deep links, API envelopes/data shape, loading/empty/error states, theme/icon context, and behavior of any theme-volume JavaScript that still reads template DOM IDs. A React shell marker alone is not proof that the Jinja dependency has been retired. + +Migration should proceed by bounded route groups. Add focused route/API parity evidence and a page-level frontend test for each group; use a populated local instance for visual review. Do not count a route as complete based only on `AdminRoutes.tsx` registration or source deletion. The inventory does not approve removal of the Flask/Jinja runtime or any template. + +## Strict zero-Jinja constraint + +`requirements.txt` pins Flask 3.1.3 and Jinja2 3.1.6. The source currently has zero authored template files, zero Jinja render calls, and zero direct Jinja imports, but Flask still owns app construction and the compatibility WSGI route tree; Flask therefore continues to require Jinja2 transitively. Removing the direct Jinja pin alone would not satisfy the user's 100% runtime goal. The owner approved FastAPI with existing-session compatibility on 2026-10-10. P17 is implementing that direction while preserving the signed-session boundary. Flask/Jinja can be removed only after the primary runtime, remaining routes/extensions, auth, CSRF, errors, static serving, emails, and compatibility contracts have parity evidence. + +## Evidence limits + +The current 2026-10-13 static AST census finds 548 Python files under `oneirodex`, 234 files importing Flask or a Flask extension, 648 Flask blueprint decorator calls with 570 unique literal route paths, zero authored template files, zero Jinja render calls, and zero direct Jinja imports. This does not include copied theme-volume JavaScript, prove every test passes, or establish production behavior. Route and visual acceptance evidence remains separately scoped in the dated migration records and private matrix. + +## Native admin route progress + +Earlier pilot checkpoint: `ONEIRODEX_NATIVE_ADMIN_PILOT` selected FastAPI page shells and optionally allowed a WSGI rollback. That switch and rollback path have since been removed; the packaged admin shell is now required by the container entrypoint. The shared manifest in `oneirodex/runtime_native/__init__.py` drives registration, dispatch and authorization checks. Some React-owned routes use the shared Python document shell in compatibility services; their Jinja page bodies have been removed while form/API compatibility remains. + +| Workflow / native GET | Preserved actions and local acceptance | +|---|---| +| Users — `/admin/users` | Existing member management API; guarded fixture edit/readback and theme/preferences checks. | +| Announcements — `/admin/announcements` | Validated composer API, admin/CSRF and draft/publish contract; temporary unpublished draft create/readback/refresh. | +| Invites — `/admin/invites`, `/admin/manage_invites` | Existing invitation workflow plus validated quota add/subtract JSON action; UUID, strict integer bounds and row locking. Native roster errors retain the shared Page shell and can be retried. Alias GET uses the same page; legacy form POST remains as the pilot-off fallback. | +| File Extensions — `/admin/extensions` | Existing CRUD API; add/filter/refresh/remove with a temporary suffix and named native quick-add buttons. | +| Whitelist — `/admin/whitelist` | JSON list/add, email normalization/validation, duplicate errors, registered status and audit events; existing confirmed delete route. Temporary record lifecycle verified. | +| Remote Play — `/admin/remote_play` | Existing config API with shared CSRF/error handling, styled inputs, load retry and draft preservation; settings save/readback/refresh and controlled failure states. Remote host operation remains outside this page acceptance. | +| Quality Profiles — `/admin/quality_profiles` | Existing create/edit/activate/score/delete APIs; temporary profile lifecycle and confirmation verified, original store restored. | +| ROM reference sets - `/admin/reference_sets` | DAT upload/confirmed replacement/delete, rehash, four-bucket repair preview and licensed catalog refresh retained. Real synthetic DAT lifecycle, invalid replacement preservation and read-only preview verified; disk hashing/provider refresh mocked. | +| Features — `/admin/features` | Seven household preferences, 21 read-only process flags, challenge solver form/Test and sanitized scanner diagnostics retained. Native pilot also exposes ambient-lighting config with write-only credentials and an explicitly confirmed connector probe; its save/readback/restore and cancel-without-probe were verified. Existing preferences and solver saves retain their safety guards. | +| Household chat emoji — `/admin/chat_emoji` | Shared upload form and named sortable table; cap/type/size/resize constraints, preview and confirmed deletion retained. Real owned PNG upload/preview/refresh/cancel/delete at phone/desktop widths verified, including file removal and failed-load Retry. | +| IGDB metadata — `/admin/igdb_settings` | Safe credential state/save with secret presence only, blank-secret retention and explicit saved-config test; existing legacy routes preserved. Real dummy settings save/readback/refresh and UI recovery verified; provider test mocked. | +| Attract Mode — `/admin/attract_mode_settings` | Full admin state/options/bounds and strict settings save API; timeout, library/genre/theme/year filters, autoplay/skip and preview preserved. Real persistence/error Retry verified; populated multi-select array persistence and explicit names verified. No playback. | +| Dashboard, Ops, Artwork and Danger — `/admin`, `/admin/dashboard`, `/admin/ops`, `/admin/art_studio`, `/admin/images`, `/admin/system/danger` | Existing React APIs/actions retained; shared artwork Page shells and accessible Art Studio tabs. Direct entry/refresh, image query and art tab deep links verified; dangerous/external/file mutations remain mock-only. Narrow art-tab controls wrap as full labels; recapture verified. | +| Support and Ownership — `/admin/support`, `/admin/ownership` | Shared list/error/retry and named table states; real disposable ticket filtering/resolution/readback and ownership diagnostics refresh verified. GitHub messaging and live store repair are excluded; repair concurrency covered by mocked component checks. | +| Detail Layout and Emulators — `/admin/detail_layout`, `/admin/emulator_profiles` | Existing layout reorder/visibility/save/reset and core preference APIs; wrapping rows, unique checkbox names, save guards and errors. Real settings persistence/refresh and emulator tab keyboard verified; extra panels retained. Play, achievements matching and firmware operations remain outside this bounded acceptance. | +| Plugins and Settings — `/admin/plugins`, `/admin/settings` | Registry sorting/refresh, named table/scroll region and error Retry; Settings tab focus, arrow wrapping, Home/End and configured links verified. No plugin install. | +| AI Assist — `/admin/ai` | Native React page uses the existing admin APIs for module config, explicit Ollama connection checks, suggestions and doctor notes. Applying a title to an existing game is separately confirmed and remains disabled unless the environment auto-apply gate is on. Legacy route stays as pilot-off fallback. Focused React and route tests pass; populated native browser review remains open. | +| Arr module — `/admin/arr` | Native React page preserves module toggle, write-only connector secrets, native indexer CRUD, bulk import, presets, search and hardlink preview/apply. Sending search results to qBittorrent and applying previewed hardlinks require separate confirmations; hardlink Apply reflects the environment gates. Legacy route stays as pilot-off fallback. Focused React/API/route tests and build pass; populated browser review remains open. | +| Scan/Match — `/admin/scan_match` | Existing policy API, shared numeric/select styling, required values, loading/save guards and failed-load Retry; real settings save/readback/refresh and controlled errors verified. No scan started. | +| Server Settings - `/admin/new_server_settings` | All 28 controls retained with explicit labels, numeric bounds, safe relative nested folder support, shared fields and recovery. Strict partial-save API preserves unrelated JSON and columns; historical URL credentials are scrubbed. Real disposable save/readback/refresh and failure recovery verified; no scans or provider calls. | +| Newsletter and history detail - `/admin/newsletter` and `/admin/newsletter/` | Shared composer retains member recipient selection, select all/none, SMTP and Server Settings prerequisites, history and pending/sent/failed truth. Plain message text is escaped; failed transport details are reduced to a generic hint. Real owned history/detail refresh and intercepted send/cancel/failure/retry at 375/1440; exact numeric shell matcher rejects adjacent paths. No mail sent; reviewer and records cleaned. | +| OIDC settings — `/admin/integrations/oidc` | Dedicated optional native page and strict settings API; existing `/admin/integrations#oidc` tab remains reachable when the native pilot is off, while the other legacy integration tabs keep their existing host. Responses reveal secret presence only; blank secret preserves the configured value. Live 375/1440 save/readback and compatibility-link checks pass; no identity provider contacted. | +| Integrations overview — `/admin/integrations` | The existing React grouped overview is now served directly through optional native static dispatch. Provider inventory and RetroAchievements/OIDC settings stay behind their existing admin APIs; service rows link to their owning settings pages. `#meta_quest` and `#indexers` destinations plus inventory links pass authenticated 375/1440 review with intercepted provider responses. Classic tabbed Jinja route remains the flag-off fallback. | +| Downloads admin — `/admin/manage-downloads` | Native shared table and admin-only list API preserve username, partial game UUID, status, file basename and timestamps without returning the full filesystem path. Existing admin delete endpoint remains behind confirmation. Live 375/1440 table, Retry, cancel and intercepted deletion pass; no request record or file was deleted. | +| Statistics — `/admin/statistics` | Native page retains all six existing activity datasets, exact totals and the ranked title table. Proportional bars expose labels and exact values; the admin-only API uses the shared response envelope. Live 375/1440 data-intercepted Retry and populated states pass with no overflow or page errors. | +| Scan jobs — `/admin/scan-jobs` | Existing React scan progress/retry and unmatched review page now has direct native static dispatch and a Libraries & scans rail destination. Its scan-start link retains the existing Auto/Manual route. Authenticated 375/1440 review used intercepted running/failed jobs and unmatched responses; table region is named and keyboard-scrollable. The broader library, tools, auto/manual, filters and unmatched migration remains open. | +| Libraries — `/libraries` | Added opt-in native static dispatch to the shared React create/list/edit/delete manager, with the legacy shell retained while the pilot is off. Direct `/admin/library/add` and `/admin/library/edit/` are also native in the pilot; edit uses the admin GET/PUT API. Roster failure retains the shared Page shell and can be retried; the Scan controls link points to the native page. Focused tests and current-source 375/1440 route captures pass. Scan management and tools remain separate Jinja workflows. | +| Libraries tools — `/libraries` | Optional native panels expose folder proposal and CSV/JSON import, suggested-match review/approval, folder-name doctor, single-game rename and freshness checks. Six panel states were opened at 375/1440 without overflow or page errors. Create, write, rename and proposal-clear actions retain their explicit review/confirmation steps; data-dependent action review remains pending because the synthetic browser dataset has no games. | +| Per-game artwork — `/edit_game_images/` | The direct game-card/editor path now dispatches to shared React chrome; image roster and game state use the existing admin APIs, uploads/deletes retain CSRF-backed multipart/JSON calls, provider selection uses ArtworkPicker, and refresh requires confirmation. The cover preview and controls share a two-column desktop panel that stacks on phones. Authenticated 375/1440 review intercepted upload/delete and canceled provider refresh. The default Flask route emits the Python-built shell; Flask route compatibility remains until runtime cutover. | +| Scan management tabs — `/scan_management?active_tab=…` | Default-off static shell maps Auto/Manual, jobs, tools/libraries, unmatched, scan filters, file extensions and image queue deep links to their React pages; `/scan_manual_folder` redirects to the Manual Identify destination. Current-source authenticated route captures at 375/1440 pass without overflow or page errors. Manual Identify lists read-only candidates while idle, preserves the requested library, and presents the shared Queue/Force conflict dialog when a scan is active; the choice starts or queues only after confirmation. Data-dependent action acceptance remains open. | +| Automatic scan and Manual Identify — `/admin/scan-controls`, `/add_game_manual`, `/game_edit/` | Native Auto workflow uses existing library/root and folder-browser APIs and shared queue/conflict modal. Manual list returns read-only candidates while idle; when busy it first returns a conflict without creating a job, then the shared dialog lets the operator Queue or Force. Identify and edit use a React form with IGDB and supported provider search, metadata/taxonomy controls, admin-only validated create/update APIs, safe-path and duplicate checks, unmatched cleanup, edit scan guard, provider links, and existing post-save side effects. Link existing game remains a separately confirmed JSON action. `/add_game_manual` and `/game_edit/` retain Flask/Jinja fallback when the native pilot is off. Exact-segment ASGI dispatch now includes both direct edit paths; bounded route tests pass and `/game_edit/` was captured at 375/1440 with a temporary row. Read-only idle listing and rename preview used temporary fixtures and no scan/apply action. Provider search, link, identify save and busy-scan actions remain open. | +| Unmatched folders — `/admin/unmatched` | Existing `DupeGlance` review actions now have a native direct-entry page. The standard Flask path preserves compatibility by redirecting to `/scan_management?active_tab=unmatched`; native shell dispatch serves React while the pilot is enabled. Existing guarded correction APIs are reused. Full authenticated phone/desktop review is recorded in the private migration report; no provider or scan action was started. | +| Themes — `/admin/themes` | Native page now preserves current-account theme context, installed/default theme metadata, validated theme ZIP upload, confirmed custom deletion, grouped loading-motif catalogue/save and confirmed default reset. Admin APIs are session/admin guarded; multipart keeps browser-generated boundary. Authenticated 375/1440 checks intercept every write; classic form fallback and `/admin/themes/readme` remain. | +| Theme authoring guide — `/admin/themes/readme` | Read-only structure/metadata/CSS/ZIP guidance now has a React static-shell route and back link when the pilot is enabled; classic Jinja route remains the fallback. Full content and direct entry checked at 375/1440. | +| Discovery Sections - `/admin/discovery_sections` | Manual local search/UUID ordering, library/platform/genre filters, built-in restrictions, visibility/order, UTC schedules and separate pin saves retained. Real owned shelf lifecycle and failure recovery verified at phone/desktop widths; original shelf state restored. | +| SMTP — `/admin/smtp_settings` | Validated state/save API, password-presence-only responses and blank-password preservation; shared form, load Retry and draft retention. Saved-config connection test is explicit and sends no mail; only mocked transport verified. | +| Storage — `/admin/storage` | Shared Page shell; unknown status with Retry; known helper/apply gates required before enabling actions. Real status and controlled preview/error checks verified. Hardlink Apply remains unperformed. | + +Accepted browser slices cover phone and desktop widths against an isolated local test database. The current source route audit mapped 38 Jinja page-route patterns to native static or exact dynamic dispatch; the per-game artwork editor and `/scan_manual_folder` alias are included. Focused native route tests cover login/admin authorization, no-store responses and shell dispatch. A current-source authenticated smoke pass rendered all 59 registered native admin paths and scan deep-link variants at desktop width with no browser errors; only the four documented no-confirmation, read-only reset-plan previews were allowed, and all other non-GET requests were blocked. The sweep checked page rendering, not every control state. Current 375/1440 reviews cover intercepted image upload/delete and Auto Scan submissions, plus the Manual Identify compatibility redirect. The full admin-app suite passes 590/590 tests, with 56 focused Libraries/scans API/schema tests and 78 native dispatch tests also passing. These validate component behavior and server contracts; live provider execution, a scan run, and filesystem Apply were not performed in this UI review. Physical accessibility and the final zero-Jinja gate remain later acceptance work. + +### Work continuing after P15 admin migration + +| Area | Remaining acceptance | |---|---| -| Templates | 48 | -| Server-rendered bodies (`auto` / `legacy`) | 28 pages + 3 partials | -| Template lines still server-rendered | 5,000 | -| Handler JS files still loaded by a Jinja page | 33 | +| Libraries & scans operations | Live provider calls, scan execution and filesystem Apply were not run in this UI review. Validate them separately in an isolated environment before any production use. | +| Admin route/action review | P15 native page and API acceptance is complete. Keep the reversible Jinja fallback until the later zero-Jinja gate passes; do not remove handlers before then. | +| Accessibility and devices | Complete physical keyboard/screen-reader and hardware/device review beyond the captured browser widths. | +| Member, authentication and setup | P16 implementation and acceptance are complete for the mapped member/login/setup/settings and game-management workflows. Shared-envelope auth, password-reset and confirmation APIs preserve throttling, account-state gates, invite/allowlist rules, token expiry, CSRF and existing sessions. The opt-in FastAPI member shell serves React login/register/reset/confirmation and six setup steps at classic URLs; setup transitions enforce order/admin gates, do not return secrets, retain the created session and keep OIDC disabled. Profile, avatar, password, invites and tokens use the shared account modal/API; preferences use a shared React dialog and validated `GET` / `PUT /api/account/preferences`, with theme/icon/font/page-size/sort/tile/language/player controls, previews, folding state, theme reset and install affordance. Manual game add/edit remains on its React admin pilot/API path from P15. Focused API/UI tests passed; populated local browser review covered login, registration/reset/confirmation errors, setup skip flow, direct profile/avatar/password entry and refresh, invite create/revoke, preferences persistence and 375/1440 layouts without page errors. Legacy Jinja handlers remain behind the default-off pilot flags for rollback; remove them only at the global zero-Jinja acceptance gate. No email was sent, provider or filesystem action was applied, and no production/Unraid deployment occurred. | +| Non-page outputs and runtime | **P17 in progress:** daily digest, social-notification and invite email bodies use escaped Python renderers; `/offline` returns a self-contained dark-green document; theme and distribution asset URL resolution now lives in `oneirodex/utils/asset_urls.py` and the FastAPI admin shell calls it directly. `/pulse`, `/awake`, `/offline`, `/sw.js`, `/vr/sw.js`, `/favicon.ico`, public static files, `/api/theme/fonts.css`, `/api/emulator/installed-cores.js`, `/manifest.webmanifest`, `/api/loading-icon`, and credential-free `/api/setup/state` requests bypass Flask request dispatch. Readiness, manifest, setup-state, font CSS, loading-icon, anonymous attract-mode, SSE authorization/event filtering, OIDC labels, and ASGI download reads use native response/ORM boundaries; ASGI no longer pushes Flask app context. The API router still initializes as part of the transitional Flask startup. Credential-bearing attract-mode requests retain the per-user override path. Credential-free `GET`/`HEAD /login` and `/reset_password_request` serve the validated React auth shell; credential-bearing requests retain Flask-Login behavior and POST remains on Flask. `GET /api/auth/runtime-bootstrap` uses the native signed-session/CSRF-compatible handler; credential-free `/confirm/` visits serve the checked React shell directly, while requests with credentials retain Flask auth/flash behavior. Flask remains the primary runtime and Jinja2 remains a Flask dependency. Full FastAPI route/session parity and final package/runtime removal are still open. | +| Final zero-Jinja acceptance | Route/action regression, populated captures, documentation, accessibility, output parity and package dependency checks across every surface. | + +**React-only shell follow-up, 2026-10-13:** `/admin/chat_emoji` and `/admin/smtp_settings` now return the shared Python shell. Their existing React screens continue to own list/form state through the guarded APIs; classic multipart emoji and SMTP JSON POST routes remain available. The document requests no longer query emoji rows or SMTP settings. Page-only chat emoji and SMTP settings templates were removed; the SMTP stylesheet/script remain because `/admin/integrations` still references them. Verification: DB-free shell/API/icon suite 66/66 and ChatEmojiPage/SmtpPage Vitest 8/8. The DB-backed legacy SMTP POST compatibility matrix was not run because `TEST_DATABASE_URL` is unavailable. Current census: 49 HTML files, 69 literal render calls, 29 unique literal names, zero dynamic template names. JIN-07 remains open. + +**Native anonymous font CSS follow-up, 2026-10-13:** `GET` and `HEAD /api/theme/fonts.css` now use a native ASGI response when the request has no cookies or authorization credentials. Font catalogue and stylesheet helpers accept an explicit font root, preserving query-selected operator fonts; requests carrying credentials continue through Flask so user font preferences, remember cookies and request-loader behavior are unchanged. Verification: DB-free ASGI/static/theme-asset suites passed 32/32; compileall and scoped `git diff --check` passed. No database or live server was used. Flask remains the primary runtime, so this is a bounded request-dispatch migration only; JIN-07 remains active. + +**Native WebRetro installed-core allowlist, 2026-10-13:** `GET` and `HEAD /api/emulator/installed-cores.js` now return the existing disk-backed shipped-plus-operator core list directly from ASGI. The JavaScript payload, content type, five-minute private cache, and HEAD content length are covered; `tests/test_asgi_native_pilot_dispatch.py` passed 23/23. Flask remains the primary runtime and JIN-07 stays active. + +**Native install manifest response, 2026-10-13:** moved the manifest payload into a shared framework-independent builder and route `GET`/`HEAD /manifest.webmanifest` through ASGI. The handler reads the existing site label inside app context and retains install metadata, `application/manifest+json`, the public one-hour cache, security headers, and HEAD content length. The DB-free ASGI dispatch suite passed 24/24; JIN-07 remains active. + +**Native loading-icon bootstrap, 2026-10-13:** `GET` and `HEAD /api/loading-icon` now read the existing member-safe settings payload in Flask app context and emit JSON directly from ASGI. The payload, content type, security headers, and HEAD content length are covered with a mocked settings payload; the DB-free ASGI dispatch suite passed 25/25. JIN-07 remains active. + +**Native anonymous auth shells, 2026-10-13:** credential-free `GET` and `HEAD /login` and `/reset_password_request` now serve the validated built React auth shell directly. Requests with cookies or authorization continue through Flask-Login so signed-session redirects and request-loader authentication retain their behavior; form posts are unchanged. DB-free ASGI shell, credential-fallback, and compatibility coverage passed 27/27. JIN-07 remains active. + +**Native anonymous setup-state API, 2026-10-13:** credential-free `GET` and `HEAD /api/setup/state` now return setup status directly from ASGI. Step one exposes only the existing safe platform list and empty settings; in-progress later steps return the existing unauthorized envelope without a signed admin session. Credential-bearing requests retain Flask dispatch. No-store/security headers and HEAD length are covered; the DB-free ASGI dispatch suite passed 29/29. JIN-07 remains active. + +**Native anonymous attract-mode settings, 2026-10-13:** credential-free `GET` and `HEAD /api/attract-mode/settings` read global defaults in request-local ORM scope and return them from ASGI. Cookie- or authorization-bearing requests keep the Flask-Login path that merges user overrides. A mocked settings row verifies the payload; the focused anonymous ASGI behavior check passed with the neighboring native readiness/setup/font checks. JIN-07 remains active. + +**Earlier P17 strict-runtime census checkpoint, 2026-10-14 (superseded):** that source scan found 554 Python files under `oneirodex`, 211 files importing Flask or a Flask extension, zero template files, zero Jinja render calls, and zero direct Jinja imports. At that checkpoint, the default runtime still constructed Flask and used `a2wsgi.WSGIMiddleware`. Subsequent work removed normal Flask construction and the WSGI bridge; service imports and framework package removal remain acceptance gates. + +**P17 locale API follow-up, 2026-10-13:** `GET`/`HEAD`/`POST /api/locale` now dispatch through FastAPI with the shared request-auth and CSRF compatibility boundaries. JSON and form submissions preserve the member locale preference, signed-session `locale` value, `gt_locale` cookie, and shared security headers. Persistence remains behind an injectable data adapter and uses the existing ORM inside Flask app context; the legacy Flask route remains registered for fallback compatibility. The ASGI native-route suite passed 151 tests. This moves one request family but does not change the strict Flask/Jinja runtime gate. + +**P17 unused extension removal, 2026-10-13:** Removed Flask-Babel initialization, configuration keys, and its runtime requirement after a repository call-site audit found no translation calls and no consumers of its selected locale. Locale values and normalization remain in the framework-independent `utils/locale_core.py`; the Flask compatibility route and FastAPI route both use that module. Verification: the native ASGI route suite passed 151/151, the locale normalizer check passed, compileall and API-envelope lint passed, and source search found no Flask-Babel or old `utils.i18n` references. This removes one Flask extension; Flask itself still pulls Jinja2 and remains the primary runtime. + +**P17 FastAPI auth resolver boundary, 2026-10-13:** The 48 native API router registrations and the optional admin bootstrap no longer call Flask-Login's user-loader callback. They resolve existing `:` sessions through the framework-independent auth resolver and persistence callbacks; the Flask user-loader remains for compatibility-shell requests. This preserves session freshness/protection, remember-cookie resolution, active-account checks, CSRF/session issuance, redirect messages, and Bearer scopes. Combined native admin, ASGI, auth-resolver, and request-auth suites passed 250/250. JIN-07 remains active because data persistence and primary app construction still use Flask. + +**P17 native error-envelope boundary, 2026-10-13:** Extracted stable error codes and failure-body construction into `utils/api_contract.py`, which has no Flask imports. Both legacy `api_error()` and FastAPI's 404/500 handlers use the same builder; native API error handlers now take config/logger values and do not enter Flask app context to serialize failures. Flask remains the primary startup and persistence runtime, so this is a bounded decoupling step, not JIN-07 completion. + +**P17 lazy legacy package and ASGI imports, 2026-10-13:** Moved Flask application construction and extension objects behind lazy exports in `oneirodex/__init__.py`, retaining the implementation in `oneirodex/legacy_app.py`. The ASGI module now defers legacy app, model, ORM, security, static and download helper imports until a compatibility request needs them. A subprocess assertion confirms `import asgi` and `oneirodex.utils.api_contract` load neither Flask nor Jinja; requesting `create_app` or `db` still loads the legacy runtime. ASGI/static/SSE/browse regressions passed 192 tests (one environment-dependent skip); the API response and native admin/member suites passed 105; compileall, API-envelope lint and diff checks passed. Lifespan startup and ORM-backed route families still initialize Flask, so JIN-07 remains active. + +**P17 native SQLAlchemy session boundary, 2026-10-13:** Added a context-local request session scope that binds to the already configured database engine without pushing Flask's app context. Flask-SQLAlchemy sessions still use Flask app-context scoping for legacy routes. The native admin auth bootstrap now uses the request scope and framework-independent remember-cookie signing; member bootstrap's OIDC label read uses it as well. SQLite-backed session tests proved ORM reads/writes work outside Flask context and legacy context-scoped sessions still work. Native DB/auth/request-auth/admin/member/ASGI regressions passed 259/259; compileall and scoped diff checks passed. Most native route persistence and lifespan still use Flask. + +**AI assist shell follow-up, 2026-10-13:** `/admin/ai` now returns the shared Python shell and relies on the existing `/api/ai/config` flow for form state. Removed the page-only Jinja template and its JavaScript after reference review; the React screen already implements the configuration and diagnostic actions. Verification: AI/Arr/settings module-status source contracts plus admin shell and icon checks passed 43 cases except the initial stale Arr-template assertion; after replacing that assertion with the React/API contract, the focused suite is green. `AiAssistPage` component verification is pending. Census: 48 HTML files, 68 literal render calls and 28 unique literal names. + +**Libraries shell follow-up, 2026-10-13:** the admin-only `/libraries` document route now emits the shared shell and lets the mounted React Libraries & scans experience load through its existing guarded APIs. Removed its former Jinja host; the shared partial and classic assets remain because the manual scan compatibility page still uses them. Existing admin/login decorators are unchanged, and the route no longer queries the library roster or game count for document HTML. Verification: shell/chrome/icon regression group 65/65; the database-backed route test was updated but not run because `TEST_DATABASE_URL` is unavailable. Current census: 47 HTML files, 67 render calls, 27 unique literal template names. + +**Game identify and image-editor shell follow-up, 2026-10-13:** manual add/edit routes now return the shared admin shell on direct GET, retaining original URLs/query prefill and admin protection; edit still verifies existence before returning 200. The React identify workflow loads options/game state and performs edits through the guarded API, while classic form POST handlers remain available and use shell/flash output for compatibility. `/edit_game_images/` also serves the shared admin shell; React reads game/image state through its existing APIs. The legacy `/playromtest` route now displays its guidance in the shared member SPA shell. Removed the manual-identify, image-editor and playrom templates, the playrom-only stylesheet, and the obsolete base-template special case. Verification: manual-identify API/preview + icon/admin-shell checks 41/41; ManualIdentifyPage 2/2; EditGameImagesPage 1/1. DB-backed legacy route tests remain unrun without `TEST_DATABASE_URL`. Census: 44 HTML files, 37 literal render calls, 24 unique literal names, zero dynamic. + +**Library add/edit shell follow-up, 2026-10-13:** `/admin/library/add` and `/admin/library/edit/` now serve the existing React Libraries/create/editor experiences through the shared admin shell. The classic multipart/form POST paths remain, and invalid submissions still use the shared flash layer. Removed `admin_manage_library_create.html` and its now-unused Cropper template include; the reusable platform/image APIs remain. Verification: `tests/test_template_icons.py`, `test_vendor_licensing.py`, `test_chrome_parity.py`, and `test_admin_shell.py` passed 87/87; LibrariesPanel/LibraryEditor/library scan tests passed 20/20. Database-backed legacy route tests were not run because `TEST_DATABASE_URL` is unset. Census: 42 HTML files, 29 literal render calls, 23 unique names. + +**Libraries & scans shell follow-up, 2026-10-13:** the remaining scan and library-tools document routes now use the shared Python shell and defer visible state to the existing React/API surfaces. The manual scan, auto scan, jobs, unmatched, filters, extensions, image queue, and library tools deep links dispatch in `AdminRoutes`; classic POST handlers remain for compatibility and error flashes. Removed `admin_manage_scanjobs.html` and its three now-unreferenced partial templates. Verification: Python API/shell/chrome/icon regressions passed 99 cases; after correcting six stale assertions that still assumed Jinja markup, the focused shell/chrome/icon suite passed 67/67. The React ScanControls, Scans, Unmatched, Images and DupeGlance tests passed 43/43. The long API batch also completed; no provider, scan, file or external write action was performed. Database-backed classic route tests remain gated by the missing `TEST_DATABASE_URL`. Census: 38 HTML files, 27 literal render calls, 22 unique names, zero dynamic. + + +**Integrations shell follow-up, 2026-10-13:** `/admin/integrations` now serves the shared React shell. The metadata provider toggles use the existing `GET|PUT /api/admin/integrations/metadata-providers` contract; the optional community URL/label now use an admin-only, CSRF-protected `GET|PUT /api/admin/integrations/community` API with the existing safe-host validator. The page keeps legacy navigation fragments, links to custom emoji/reference sets, provider inventory, OIDC and RetroAchievements settings. Removed the retired integrations Jinja page, five exclusive form/status partials, and its tab-only CSS/JS. DB-free API harness tests: 14 passed; React integration/panel tests: 8 passed; template/chrome/shell checks: 63 passed; CSS token lint passed. Admin-app typecheck remains red from pre-existing errors in SectionTopNav, ManualIdentifyPage and statusLanguage tests (the duplicate import introduced in the IntegrationsPage test was removed). Current census: 32 HTML files, 26 render calls, 21 unique literal names, zero dynamic names. JIN-07 remains active; DB-backed compatibility tests and populated visual captures remain open. + +**Native member bootstrap follow-up, 2026-10-13:** the standard ASGI dispatcher now serves `GET /api/auth/runtime-bootstrap` through the shared FastAPI session-compatibility handler even when the member shell pilot is disabled. It retains the existing flat API envelope, OIDC labels, CSRF token, signed session contents, cookie attributes and `flask-session-compat` runtime marker; the optional pilot reuses the same implementation and keeps its pilot marker. The settings lookup still uses a Flask app context, while login, account page and mutation routes remain on the compatibility runtime. Verification: DB-free ASGI dispatch, member-pilot, session-codec and legacy Flask bootstrap tests passed 36/36; compileall and scoped `git diff --check` passed. JIN-07 remains active. + +**Native confirmation document follow-up, 2026-10-13:** credential-free GET/HEAD requests to the exact `/confirm/` path serve the validated built React shell directly from the application static tree. Requests with cookies or authorization keep Flask dispatch for queued flashes and request-loader authentication, and an enabled member pilot retains precedence. The one-time confirmation API remains unchanged. Verification: DB-free ASGI, member-pilot and legacy auth-shell tests passed 29/29; compileall and scoped `git diff --check` passed. JIN-07 remains active. + +**Native readiness response follow-up, 2026-10-13:** `GET` and `HEAD /awake` run the existing DB and initialization checks in request-local ORM scope and send the existing JSON/status contract directly from ASGI, bypassing Flask request hooks and app context. The framework-independent helper accepts config/database/init dependencies, while Flask callers retain defaults. Verification: DB-free ASGI tests cover mocked 200/503 handling and a fake database helper check; focused readiness, manifest, setup-state, and theme-font ASGI checks passed 6/6. This is not DB-backed readiness evidence; JIN-07 remains active. + + +**Shared native bootstrap authentication, 2026-10-13:** the admin runtime-bootstrap now uses `request_auth_context()` instead of duplicating signed-session, remember-cookie, and Bearer resolution inline. Persistence callbacks and the configured Flask-Login user loader remain injected inside the existing app context. DB-free parity group `tests/test_native_admin_pilot.py`, `tests/test_native_request_auth.py`, and `tests/test_native_auth_resolver.py`: 94 passed. The helper is now exercised by a production-path pilot endpoint, but it has not yet replaced the Flask request runtime for the rest of the route inventory. JIN-07 remains active. + + +**Native trailer API vertical, 2026-10-13:** `/api/trailers/filters` and `/api/trailers/random` now dispatch through a FastAPI router from the outer ASGI app. The handlers use `request_auth_context()`, preserve existing query/payload and empty-state behavior, and keep query access plus auth resolution in one explicit Flask app context while the SQLAlchemy extension is still retained. Anonymous access keeps the Flask-Login redirect, flash, and signed-session cookie; GET/HEAD, security headers, YouTube conversion, malformed filters, and safe error envelopes are covered. The combined native trailer/auth/admin/bootstrap/ASGI dispatch group passed 139 tests. This does not complete JIN-07: the remainder of the route tree and the primary Flask/WSGI runtime remain active. + +**Member account and release calendar follow-up, 2026-10-13:** FastAPI now dispatches `/api/account/avatar` multipart uploads, `/api/account/password`, `/api/account/preferences`, `/api/account/avatar/stock`, `/api/account/summary`, invite list/create/revoke, and `/api/tokens` list/create/revoke, plus `GET`/`HEAD /api/calendar`. Password change preserves the Flask-Login password fingerprint rotation, current signed-session refresh, and remember-cookie refresh. Avatar upload uses the established image validation/storage service through a bounded multipart reader; calendar and account persistence remain behind adapters and an explicit app context while the SQLAlchemy transition continues. Contract plus outer-ASGI dispatch coverage across all `test_native_*.py` files and `test_asgi_native_browse.py` passed 296 tests; only the upstream Starlette/httpx deprecation warning remains. This advances endpoint coverage; Flask still owns the primary runtime, and the remaining routes, middleware parity, app construction, and Flask/Jinja2 dependency removal are open. + +**Companion capability and lifecycle API follow-up, 2026-10-13:** GET/HEAD `/api/client/capabilities` and GET/HEAD/POST `/api/client/lifecycle` now dispatch through FastAPI. The native path preserves first-value `device_kind` parsing, `read:library` lifecycle reads, the Bearer-only write requirement, the accepted lifecycle scopes, and body-validation-before-Bearer-only denial order. Fake adapters cover read/write payloads; no lifecycle file was written. The expanded DB-free native-route and outer-dispatch suite passed 304 tests with one upstream Starlette/httpx deprecation warning. The route set and primary runtime conversion remain in progress. + +**Companion heartbeat and command APIs, 2026-10-13:** `/api/client/heartbeat` and `/api/client/commands` GET/POST plus `/ack` and `/nack` POST now use the native FastAPI router. Browser heartbeat and command queue still require CSRF; command queue still checks per-game access and download scope for install-mutating actions; polls require read scope and a companion token; acknowledgments remain bearer-only. Fake adapters prevent disk/database command writes. The complete DB-free native-route/outer-dispatch suite passed 314 tests with the same single upstream Starlette/httpx deprecation warning. Strict runtime retirement remains open. + +**Announcement and news-feed APIs, 2026-10-13:** `/api/announcements` GET/HEAD/POST and GET/HEAD `/api/news/gaming` and `/api/news/free-games` now dispatch through FastAPI. Admin draft visibility, validated/CSRF-protected creation, first-value feed query parsing, bounds, disabled responses, and empty provider-failure fallbacks remain covered. Provider/data adapters are faked in route tests; no outbound request ran. The consolidated native-route and outer-ASGI dispatch suite passed 321 tests with one upstream Starlette/httpx deprecation warning. Flask remains the primary runtime and strict JIN-07 acceptance is still open. + +**Native API error parity, 2026-10-13:** the shared FastAPI API app now envelopes unmatched HTTP errors and unexpected handler exceptions with the same stable `api_error` codes and status, attaches configured security headers, and returns a fixed 500 message without exception details. Outer ASGI tests cover an unmatched dynamic API path and a synthetic private exception. These checks are included in the 321-test consolidated run. The WSGI bridge remains active for unconverted paths. + +**Collection API family, 2026-10-13:** `/api/collections` list/create, collection detail/edit/delete, and item add/remove/reorder now dispatch through the FastAPI app. Ownership, private-collection visibility, system-collection restrictions, item ACL checks, validation, signed-session auth, CSRF, stable API errors and security headers remain covered. The member client already uses the shared requester for collection CRUD. Adapter fakes prevent test database writes. The consolidated native-route/outer-ASGI suite passed 334 tests with one upstream Starlette/httpx deprecation warning. + +**Wishlist request API family, 2026-10-13:** `/api/requests` list/create and per-request cancel/resolve now dispatch through FastAPI. The route retains first-value `all` parsing, member-only visibility, librarian/admin scope checks, child-account restrictions, duplicate pending-request behavior, CSRF validation, item linking and resolution fields. The member client already uses `createWishlistApi` through the shared requester. The same 334-test batch passed; tests use a fake data adapter. The primary Flask/WSGI runtime and strict JIN-07 gate remain open. + +**Notification center APIs, 2026-10-13:** `/api/notifications` list, `/api/notifications/read` mark-read, and `/api/notifications/preferences` GET/POST now dispatch through FastAPI. First-value query parsing, the 1–100 limit, `read:social` and `write:social` Bearer scopes, session behavior, CSRF-protected browser writes, scoped Bearer writes, preference projection, and shared response/security headers remain covered. Fake adapters avoid DB writes. The consolidated DB-free native-route and outer-dispatch suite passed 342 tests with one upstream Starlette/httpx deprecation warning; the existing member notification client already uses the shared requester. Flask remains the primary runtime and JIN-07 stays active. + +**Wanted-update list APIs, 2026-10-13:** `/api/updates/wanted` GET/HEAD/POST and `/api/updates/wanted/fulfill` POST now dispatch through FastAPI. Existing local wanted-list storage, request validation, game ACL checks, idempotent replace behavior, fulfillment counts, browser-session CSRF and the `api_ok` write shape remain in place. Injected adapters ensure route tests write neither files nor database rows. The consolidated DB-free native-route/outer-ASGI batch passed 342 tests with the same upstream Starlette/httpx deprecation warning. The store-search and freshness recheck routes and the primary Flask runtime remain on the migration backlog. + +**Freshness inbox and store search reads, 2026-10-13:** GET/HEAD `/api/updates/inbox` and `/api/updates/store_search` now dispatch through FastAPI. Existing accessible-game filtering, response fields, default/capped limits, library scoping, validation, first-value query semantics, search-result matching and security headers are preserved. Injected adapters kept tests clear of database and Steam/GOG requests. Consolidated native-route and outer-ASGI verification passed 346 tests with one upstream Starlette/httpx deprecation warning; API-envelope lint, Python compileall and scoped whitespace checks passed. The freshness recheck and the primary Flask runtime remain open. + +**Bounded freshness scan, 2026-10-13:** POST `/api/updates/scan` now dispatches through FastAPI. The native path preserves 25/50 batch bounds, accessible-game filtering, library and stale-window scoping, oldest-first ordering, per-title failure rows, one batch commit, and the established `api_ok` payload. Browser requests still require CSRF. Tests inject the scan adapter, so no database write or store probe ran. The consolidated DB-free native-route/outer-ASGI suite passed 346 tests with one upstream Starlette/httpx deprecation warning; envelope lint, compileall and scoped whitespace checks passed. Other legacy routes and Flask as primary runtime remain open. + +**Acquire actions, 2026-10-13:** GET/HEAD `/api/acquire/search` and POST `/api/acquire/download` now dispatch through FastAPI alongside the native status read. Child-account and librarian checks, Arr/debrid feature gates, Pydantic validation, outbound-HTTP URL validation, safe provider error wording, CSRF for browser writes, API envelopes and security headers remain covered. Tests inject indexer/send adapters, so no provider request or download action ran. The consolidated native-route/outer-ASGI suite passed 349 tests with one upstream Starlette/httpx warning; API-envelope lint, compileall and scoped whitespace checks passed. Other route groups and Flask as the primary runtime remain open. + +**Social status and friend reads, 2026-10-13:** GET/HEAD `/api/social/status` and `/api/social/friends` now dispatch through FastAPI. Both require `read:social` for Bearer clients, preserve signed-session redirects, presence-aware user projections, social API error envelopes and security headers. Fake data adapters prevent database access in route tests. Consolidated native-route/outer-ASGI verification passed 352 tests with one upstream Starlette/httpx deprecation warning; API-envelope lint, compileall and scoped whitespace checks passed. Friend mutations and the primary Flask runtime remain open. + +**Social friend mutations, 2026-10-13:** POST `/api/social/friends`, reject/block/accept actions, and DELETE `/api/social/friends/{id}` now dispatch through FastAPI. Anti-enumeration, friendship direction and block ownership rules remain in the data adapter; browser writes require CSRF and Bearer writes require `write:social`. Notification side effects are still best-effort and tests use fakes, so no friend rows or emails were created. The consolidated native-route/outer-ASGI batch passed 359 tests with one upstream Starlette/httpx deprecation warning; API-envelope lint, compileall and scoped whitespace checks passed. Flask remains the primary runtime. + +**P17 member social and game-read APIs, 2026-10-13:** `/api/activity` and GET/HEAD `/api/games/{uuid}/details`, `more_from`, `editions`, `versions`, and `freshness`, plus `/api/search`, now dispatch through FastAPI. The route family preserves first-value query parsing, child-account restrictions, library/game ACLs, `read:social` and `read:library` Bearer scope checks, path-redacted version payloads, existing payload builders, and standard error/security headers. POST `/api/games/{uuid}/freshness/check` is native with browser CSRF validation and an injected check adapter. Fake data adapters prevented DB writes, provider checks, and filesystem actions. The consolidated DB-free native-route/outer-ASGI batch passed 374 tests with one upstream Starlette/httpx deprecation warning; API-envelope lint and compileall passed. The default Flask/WSGI runtime and Jinja2 dependency gate remain open. + +**P17 member filter and playtime APIs, 2026-10-13:** `/api/filters/bundle` and `/api/library_platforms` now dispatch through FastAPI, as do playtime summary/per-game reads and session start, heartbeat and stop. Filter projections retain per-user library visibility and first-value completion-flag parsing. Playtime retains CSRF for browser writes, Pydantic body validation, session ownership, per-game ACLs and legacy payload shapes. The game-read batch also adapted local static artwork URL construction for ASGI calls without a Flask request context. All tests used fakes and did not write DB rows, trigger ambient-lighting events, run freshness providers, or modify files. Consolidated DB-free native-route/outer-ASGI verification passed 383 tests with one upstream Starlette/httpx deprecation warning; API-envelope lint and compileall passed. Primary Flask/WSGI and Jinja2 retirement remain open. + + +**P17 member action and VR API follow-up, 2026-10-13:** Native FastAPI dispatch now covers single and batch favorite/status actions, paged favorites, batch wishlist add/remove, batch freshness and artwork-refresh actions, member role/username/icon metadata, standalone filter taxonomies, social member profile/compare reads, and VR catalogue/detail/profile operations. Preserved legacy caps and payloads, ACLs, CSRF, read:social scope, librarian/admin-token checks, VR feature gate, URL validation, query first-value behavior, and HEAD handling. Fake adapters kept DB writes, provider probes, and queued image work out of tests. Consolidated DB-free native-route and outer-ASGI suite: 422 passed with one upstream Starlette/httpx deprecation warning. API-envelope lint, compileall, and scoped diff checks passed. Source census remains zero app-authored HTML templates and zero render_template calls. Strict JIN-07 is still active: create_app() constructs Flask, WSGIMiddleware remains in the primary ASGI path, and Flask's Jinja2 dependency has not been removed. +**Native single-game state actions, 2026-10-13:** `/api/check_favorite`, `/api/toggle_favorite`, `/api/get_game_status` and `/api/set_game_status` now use request-local SQLAlchemy sessions and direct Starlette responses, with existing auth, CSRF, ACL, error shapes, and favorite/status payloads preserved. Route tests fail if a Flask app context is pushed. The route/API/DB-boundary focused suite passed 175 tests; compileall, API-envelope lint, and scoped diff checks passed. Flask startup, WSGI compatibility dispatch, and the remaining native route app-context scopes still block strict JIN-07 completion. +**Admin platform options, 2026-10-13:** `/api/admin/library_platforms` now resolves the existing session inside the request-local SQLAlchemy scope and emits framework-independent JSON. Admin/Bearer scope checks, redirect behavior, envelope, security headers, and HEAD semantics are retained. A route test rejects any Flask app-context use; the consolidated focused suite passed 175/175. Strict JIN-07 remains active because most native routers and startup still depend on Flask. +**Member username-check action, 2026-10-13:** `/api/check_username` now performs signed-session auth, CSRF validation, username lookup, and error serialization without a Flask app context. The shared CSRF rejection adapter now uses the framework-independent API envelope, improving every native write route. A regression test rejects Flask app-context use for success, validation, and CSRF paths. Focused ASGI/API/DB-boundary tests passed 175/175; compileall, API-envelope lint, and scoped diff checks passed. The adjacent role and icon-pack endpoints still use Flask compatibility helpers; JIN-07 remains active. +**Member role read, 2026-10-13:** `/api/current_user_role` now resolves the existing session with the native SQLAlchemy scope and returns its small JSON projection without a Flask app context. A dedicated ASGI regression fails if Flask context is requested. The full focused ASGI/API/DB-boundary batch passed 176/176; Flask-backed icon-pack listing remains on the compatibility path. +**Member icon-pack catalogue, 2026-10-13:** `/api/icon-packs` now reads user preferences inside the native ORM scope and emits the catalogue with static URLs built without Flask `url_for`; the helper retains Flask URL generation when called from legacy request contexts. All three member metadata endpoints now have regression coverage that rejects Flask app-context use. ASGI/API/DB-boundary and icon-theme tests passed 184/184; compileall, API-envelope lint, and scoped diff checks passed. +**Release calendar API, 2026-10-13:** `/api/calendar` now resolves existing sessions in a request-local SQLAlchemy scope, serializes its payload and invalid-query envelope through Starlette, and does not push Flask app context. Calendar bounds, provider failure/empty reasons, signed-session redirects, security headers, Vary, and HEAD behavior remain covered. ASGI browse, API contract, DB scope, icon-theme, and release-calendar suites passed 192/192 (one upstream Starlette/httpx deprecation warning); compileall, API-envelope lint, and scoped diff checks passed. Provider access remains injectable and no outbound request ran. +**Quality-profile list API, 2026-10-13:** `/api/quality-profiles` now resolves admin session auth in the request-local SQLAlchemy scope and serializes its intentionally bare legacy payload through Starlette. Migration-on-read persistence, admin-token scope errors, redirects, security headers, and HEAD remain covered. The focused native quality-profile contract tests passed 5/5; the consolidated ASGI/API/DB/icon/release/quality suite passed 197/197 (one upstream Starlette/httpx deprecation warning). The DB-backed quality profile CRUD suite requires the configured test database and was not run in this DB-free slice. +**Announcement feed and authoring APIs, 2026-10-13:** `/api/announcements` GET/HEAD/POST now resolve auth and SQLAlchemy work in the native request scope. Validation, CSRF, permission and internal errors use framework-independent JSON; signed-session refresh, draft visibility, status codes and security headers remain. Added no-Flask-context tests for read, create, validation failure and CSRF failure. Consolidated ASGI/API/DB/icon/release/quality tests passed 198/198 with one upstream Starlette/httpx deprecation warning; no DB-backed test database or production email/provider was used. +**Notification inbox and preference APIs, 2026-10-13:** list, mark-read, and preference GET/POST routes now resolve session and ORM work in the request-local scope. The shared success/error payload helpers replace Flask JSON serialization while `read:social`/`write:social`, cookie-session CSRF, bearer scope behavior, query bounds, and preference payloads stay intact. Tests reject Flask app-context use across browser read/write paths. Combined ASGI/API/DB/icon/release/quality suites passed 198/198 with one upstream Starlette/httpx deprecation warning. +**Personal API-token management, 2026-10-13:** GET/HEAD/POST `/api/tokens` and DELETE `/api/tokens/{id}` now resolve auth and persistence inside the native request-local SQLAlchemy scope. Shared CSRF validation and framework-independent envelope helpers replace the local Flask-context path; role/scope restrictions, one-time secret response, validation, revocation, session refresh and HEAD behavior remain. ASGI no-context route tests and the token-contract suite passed within the consolidated 207/207 focused group (one upstream Starlette/httpx deprecation warning). No token was generated or persisted; the route data adapter was faked. +**Member remote-play status, 2026-10-13:** `/api/remote-play/status` now receives app settings explicitly and queries persisted enablement inside the native SQLAlchemy scope; it returns the same member-safe JSON through Starlette without Flask app context. Legacy callers can omit the config argument and keep Flask behavior. Tests reject context use on the route and verify config-only disabled/flag behavior. Consolidated ASGI/API/DB/icon/release/quality/token/remote-play tests passed 212/212 with one upstream Starlette/httpx deprecation warning. +**Admin AI readiness status, 2026-10-13:** `/api/ai/status` now resolves signed-session and persisted settings through the native SQLAlchemy scope, receives app configuration explicitly, and emits its legacy bare status payload through Starlette without Flask app context. Admin-token scope denial, redirects, fixed unreachable error text, and security headers remain. Verification uses injected status data and config-only flag checks; no Ollama network request ran. The consolidated ASGI/API/DB/icon/release/quality/token/remote-play/AI suite passed 218/218 with one upstream Starlette/httpx deprecation warning. +**Storage, malware-scan, and RTC status APIs, 2026-10-13:** their GET/HEAD routes now resolve auth in the native SQLAlchemy scope and return JSON through Starlette without Flask app contexts. Existing role/Bearer checks, status payloads, config defaults, headers and HEAD behavior remain. The helper functions now accept explicit app config while legacy callers retain Flask-context fallback. Route adapters were faked, so no ClamAV probe or RTC network call ran. Consolidated ASGI/API/DB/icon/release/quality/token/remote-play/AI/status suite passed 225/225 with one upstream Starlette/httpx deprecation warning. +**Arr readiness API, 2026-10-13:** `/api/arr/status` now resolves admin auth and settings reads in the request-local ORM scope, passes configuration explicitly to readiness helpers, and returns the established success/error envelope through Starlette without Flask app context. Connector config/readiness only is evaluated; no provider requests or writes ran. Auth redirects, admin-token scope, headers, HEAD and helper configuration are covered. The consolidated ASGI/API/DB/icon/release/quality/token/remote-play/AI/status/Arr suite passed 231/231 with one upstream Starlette/httpx deprecation warning. +**Settings-hub module status API, 2026-10-13:** `/api/settings/module-status` now resolves the saved status toggles in the request-local SQLAlchemy scope, passes configuration explicitly into the Arr/AI/storage projections, and emits JSON without Flask app context. Admin/Bearer checks, payload, redirects, headers, and HEAD remain. Consolidated ASGI/API/DB/icon/release/quality/token/remote-play/AI/status/Arr/settings tests passed 236/236 with one upstream Starlette/httpx deprecation warning. +**Challenge-solver and ambient-lighting status APIs, 2026-10-13:** both admin GET/HEAD routes now resolve auth, role and token-scope checks, query parsing, JSON output, and security headers in FastAPI with native request-local session scope. The status helpers accept explicit configuration and need no Flask app context; tests use fake route adapters and verify config-only status projection without contacting configured devices. Focused device/status/Arr tests passed 19/19, compileall and API-envelope lint passed. The primary Flask/WSGI runtime and Jinja2 dependency keep strict JIN-07 open. +**Filter catalogue APIs, 2026-10-13:** `/api/filters/bundle`, `/api/library_platforms`, and the taxonomy reads now resolve signed-session auth and ORM work through native request scope. The bundle supplies its fallback library-art URL explicitly, avoiding Flask's synthetic request context, and error JSON uses the framework-independent API contract. Native route tests reject request-wide Flask app-context use; the focused ASGI filter/data-adapter batch passed 157/157, including configured-prefix fallback-URL coverage. Strict JIN-07 remains open for the primary Flask/WSGI runtime and other compatibility adapters. +**Member locale API, 2026-10-13:** `/api/locale` GET/HEAD/POST now uses native request-local ORM scope and Starlette responses, with the shared CSRF check, signed-session refresh, preference cookie, and API error envelope retained. A regression rejects Flask app-context use on the read route; locale read/write/CSRF tests passed 4/4. Strict JIN-07 remains active for the primary Flask/WSGI runtime and the other app-context route families. +**Device status configuration follow-up, 2026-10-13:** challenge-solver and ambient-lighting status projections now accept explicit config mappings while preserving their Flask-context defaults for legacy callers. Their FastAPI route adapters pass configuration directly, removing the last Flask app-context calls from those routes and helpers. The DB-free device status contract suite passed 8/8; no bridge or solver probe ran. +**Library health APIs, 2026-10-13:** `/api/health/library` and `/api/health/games/{uuid}` now use native request-local ORM scope, framework-independent JSON/error envelopes, and direct Starlette responses. First-value query behavior, admin checks, game lookup/404 order, library ACL, score payload and HEAD behavior remain. Tests reject Flask app-context use and exercise success, not-found, and forbidden paths with fake data. The focused health/device/filter/locale/ASGI browse batch passed 168/168 with one upstream Starlette/httpx warning. +**OIDC readiness API, 2026-10-13:** `/api/oidc/status` now resolves session/DB work through native request scope and serializes JSON/errors with Starlette and the shared API contract, without a Flask app context. Admin and Bearer checks, signed-session redirect/flash, HEAD, and the local-only no-IdP readiness report remain covered; tests reject Flask context use. Focused OIDC/health/device tests passed 15/15 with one upstream Starlette/httpx warning. +**Batch wishlist mutation, 2026-10-13:** `/api/games/batch/wishlist` now owns a request-local ORM scope and uses the framework-independent success/error envelope across empty, validation, partial-result, and save-failure responses. Cookie CSRF, account restrictions, and item-level outcomes remain. The empty-batch regression rejects Flask app-context use; focused batch-wishlist checks passed 3/3 with one upstream Starlette/httpx warning. +**Batch favorite and play-status actions, 2026-10-13:** `/api/games/batch/favorite` and `/api/games/batch/status` now use native request-local ORM scope, shared framework-independent errors/success envelopes, and standard module logging. Existing CSRF/Bearer authorization, validation, ACL and partial-result payloads remain. New empty-batch tests reject Flask app-context use; the focused batch favorite/status/wishlist group passed 10/10 with one upstream Starlette/httpx warning. +**P17 consolidated DB-free verification, 2026-10-13:** the expanded native-route group covering ASGI browse/mutations, batch actions, locale, filters, health, OIDC, device status, calendar, tokens, AI/Arr/storage/RTC/settings and supporting contracts passed 263/263 with one upstream Starlette/httpx warning. Compileall, API-envelope lint and scoped `git diff --check` passed. No test database, provider call, device probe, or production action was used. Strict acceptance still requires removing the primary Flask/WSGI runtime and Jinja2 dependency after whole-app flow parity. +**Freshness inbox and store-search reads, 2026-10-13:** `/api/updates/inbox` and `/api/updates/store_search` now use native request-local ORM scope and framework-independent error envelopes without a Flask app context. First-value query parsing and adapter-only provider access remain. Route tests reject app-context use and use fake data so no store search was issued; targeted ASGI tests passed 4/4. +**Trailer catalogue and random selection APIs, 2026-10-13:** `/api/trailers/filters` and `/api/trailers/random` now use request-local ORM scope instead of request-wide Flask contexts. Signed-session redirects/flashes, payload envelopes, safe malformed-filter errors, and HEAD behavior remain. Direct and outer-ASGI tests passed 15/15; ASGI tests use fake data and a DB-free scope, so no real game query or provider call ran. +**P17 runtime census, 2026-10-14:** the current checkout contains zero files under `oneirodex/templates/`, zero AST-detected Jinja render calls, and zero explicit Jinja imports. The framework runtime is not retired: 217 Flask-importing modules and 25 `FlaskForm` subclasses remain across 654 first-party Python files under `oneirodex/`; `asgi.py` still constructs Flask and mounts WSGI fallback, and `requirements.txt` pins Flask and Jinja2. JIN-07 therefore remains active; this is the current strict gate and not a completion claim. +**P17 game detail route follow-up, 2026-10-14:** game detail, search, editions, versions, freshness, screenshots, image and assist routes now use the native request-local SQLAlchemy scope rather than Flask app contexts. Error and success envelopes use the shared framework-independent API contract. Assist config and data-root paths are passed explicitly while legacy Flask routes keep their context defaults; freshness audit events identify the authenticated user directly. Focused DB-free route tests passed 10/10, explicit assist-config/path test passed 1/1, API-envelope lint and compileall passed, and scoped whitespace checks passed. The native-route context census is now 9. Flask startup, WSGI fallback and transitive Jinja2 remain, so JIN-07 stays active. +**P17 Discover reads follow-up, 2026-10-14:** the six Discover section, row, zone, genre-hub, surprise and pin read handlers now use the request-local SQLAlchemy scope and no Flask app context. They retain first-value Werkzeug query parsing, bare section JSON, existing success/error envelopes, anonymous signed-session redirects, security headers and HEAD behavior. JSON serialization uses the configured Flask JSON provider directly without request globals. The route suite passed 19/19 with Flask app-context creation disabled, and outer-ASGI section/zone/surprise checks passed 3/3; API-envelope lint, compileall and scoped whitespace checks passed. Current AST census: zero templates, render calls and direct Jinja imports; 212 Flask-importing modules and 3 native app-context blocks, all in account summary/invites. The primary Flask/WSGI runtime and Jinja2 dependency still keep JIN-07 active. +**P17 account summary and invite follow-up, 2026-10-14:** account summary, invite list/create/delete now use request-local SQLAlchemy scope and framework-independent API envelopes with no Flask app context. Avatar URLs use the Flask URL map adapter and explicit theme/data-root values without Flask request globals. Invite delivery uses a quiet SMTP sender with the same reachability preflight; audit events receive the acting user id. The native account suite passed 15/15 with context creation disabled; the mail-adapter contract passed 1/1 with a stub sender; the outer-ASGI account-summary check passed 1/1. No DB-backed invite mutation or SMTP request ran. The current AST census is 217 Flask-importing modules across 652 Python files; Flask startup, WSGI fallback, and Flask-pinned Jinja2 still keep JIN-07 active. +**P17 ROM translation capability route, 2026-10-14:** `GET`/`HEAD /api/rom-translate/capabilities` now dispatch through the native FastAPI app. It preserves the bare payload, login redirect and signed flash, admin role gate, admin-token scope denial, security headers, and HEAD response. Direct route parity and outer-ASGI dispatch checks passed 4/4 with Flask app-context creation disabled. The capability projection is static; it has no payload-side filesystem or provider access, while request authentication still uses its native DB scope. Flask/WSGI startup and Jinja2 dependency remain the strict gate. +**P17 loading-icon API family, 2026-10-14:** public `GET`/`HEAD /api/loading-icon` and admin `GET`/`HEAD`/`PUT /api/admin/loading-icon/config` now dispatch through FastAPI and request-local ORM scope. The member projection stays safe and bare JSON; admin settings GET stays bare JSON; writes keep browser CSRF ordering, admin-role and Bearer-scope checks, validation errors, and the shared success envelope. Direct DB-free adapter tests passed 5/5 with Flask app-context creation disabled, and outer-ASGI admin GET/PUT dispatch passed 1/1 with fake persistence. No settings row was read or written. The primary Flask/WSGI runtime and transitive Jinja2 remain. +**P17 theme-font stylesheet route, 2026-10-14:** anonymous and credential-bearing `GET`/`HEAD /api/theme/fonts.css` now dispatch through FastAPI. First query value still overrides the signed-session user's font preference, followed by the system default; cache, stylesheet, security headers, and HEAD behavior remain. The focused theme-font/ROM/loading-icon ASGI selection passed 7/7; direct loading-icon/ROM route checks passed 8/8; compileall, API-envelope lint, and scoped diff checks passed. The route still depends on the transition's Flask startup and native ORM session scope; strict zero-Jinja runtime completion remains open. + +**P17 public manifest/setup/readiness scope, 2026-10-14:** `/manifest.webmanifest`, anonymous `/api/setup/state`, and `/awake` now read database-backed state through request-local SQLAlchemy scope without pushing Flask app context. Setup payloads retain the shared success/error envelope, JSON content type, no-store/security headers, and HEAD length behavior. Readiness accepts config/database/init dependencies; Flask route callers keep context-backed defaults. Focused readiness, manifest, setup-state, and font stylesheet ASGI checks passed 6/6. Flask app construction and WSGI fallback remain. + +**P17 SSE authorization scope, 2026-10-14:** `/api/activity/stream` and `/api/events/stream` now check event visibility and child-account eligibility in request-local SQLAlchemy scope without Flask app context. Signed-session ID resolution uses the framework-independent cookie codec and active-user/password-fingerprint check. Combined ASGI dispatch, loading-icon, ROM, and SSE tests passed 49/49 with one upstream Starlette/httpx deprecation warning; no database-backed stream was opened. + +**P17 download auth and DB scope, 2026-10-14:** ASGI ZIP/ROM ownership and game reads, signed-session cookie validation, and Bearer-token resolution now use request-local SQLAlchemy scope without Flask app context. Existing fingerprint validation, active-account checks, Bearer token expiry/scope/child restrictions, and explicit Bearer-over-cookie precedence remain. DB-free session and token compatibility checks passed in the 28/28 auth/request-auth/SSE selection; the isolated review-DB descriptor-boundary suite passed 37 with 16 platform/security skips. No production files or database were touched. All app-context blocks have been removed from `asgi.py`, but Flask app construction, WSGI fallback, legacy routes/extensions, and package removal remain open. + +**P17 verification update, 2026-10-13:** after adding the freshness inbox, batch mutations, and trailer APIs to the same DB-free focused group, the consolidated selection passed 279/279 with one upstream Starlette/httpx warning. Compileall, API-envelope lint and scoped whitespace checks passed. The strict runtime census above remains the completion gate. +**Bounded freshness scan action, 2026-10-13:** `/api/updates/scan` now uses native request-local ORM scope and framework-independent response contracts without a request-wide Flask context. The existing local version-file safety boundary is preserved by passing the app configuration explicitly to the allowed-game-root check; tests verify outside-root files are ignored. Targeted scan/local-freshness checks passed 11/11 with fake scan data, so no store calls ran. +**Gaming feeds and wanted updates, 2026-10-13:** authenticated gaming/free-game feed reads and wanted-update list/create/fulfill routes use request-local ORM sessions without Flask app contexts. The wanted-update file adapter receives the configured upload root explicitly, preserving the legacy location; native responses no longer rely on Flask `jsonify`. The route/helper/API selection passed 8/8 with fake providers and route adapters. The full Flask/WSGI runtime remains required while other app-context adapters and primary startup are still present. +**Wishlist and password mutation slice, 2026-10-13:** wishlist request list/create/cancel/resolve and `/api/account/password` now use request-local DB scopes and framework-independent response contracts without Flask app contexts. Password changes retain minimum/confirmation/current-password validation, CSRF ordering, session-fingerprint refresh and remember-cookie behavior; wishlist audit events retain their initiating user ID. Focused password/wishlist checks passed 13/13 with one upstream Starlette/httpx deprecation warning. Overall strict JIN-07 remains active. +**Browse route session boundary, 2026-10-13:** `/browse_games` now uses a request-local ORM scope and can serialize the legacy UUID/date/Decimal payload without pushing Flask app context. Browse pagination uses the shared SQLAlchemy session directly, preserving page normalization/cap, count, eager-load de-duplication, and query ordering; typed first-value query parsing and the error envelope remain. Auth, browse payload shape, serialization, native SQLite pagination, and ASGI dispatch selections passed 168/168 with one upstream Starlette/httpx TestClient warning. The populated browse query was not exercised because no configured test database is available in this task; JIN-07 remains active. +**Social and related-media native scope, 2026-10-13:** social status/friends/profile/compare/activity and game related-media list/create/delete now use request-local sessions, direct JSON contracts and no Flask app/test-request contexts. Static avatar URLs retain the configured app static path; read scope, game ACL, librarian gates, CSRF, URL validation, and HEAD handling remain. Focused social and related-media route checks passed 14/14 with fake adapters and one upstream Starlette/httpx warning. JIN-07 remains active. + +**Member favorites native scope, 2026-10-14:** `/api/favorites` now resolves auth and query data inside the request-local SQLAlchemy scope, with direct Flask-context-free serialization. Its payload preserves first-value query parsing, the legacy page/per-page caps, SQLAlchemy result de-duplication, totals, and HEAD behavior. Focused GET/HEAD tests passed 1/1 after disabling Flask app-context creation; no database-backed populated favorites query was run. + +**Acquire and social mutation native scope, 2026-10-14:** Acquire search/download and friend request/accept/reject/block/remove now use request-local SQLAlchemy scope and framework-independent JSON envelopes. CSRF, Bearer write scope, librarian checks, outbound URL validation, social ownership rules, and sanitized provider errors remain covered. Fake adapters prevented database writes, notifications, and provider actions. Focused route checks passed 11/11; no provider or notification call ran. + +**VR and saved-filter native scope, 2026-10-14:** VR catalogue/detail/profile/compatibility routes and saved-filter list/create/update/delete/preview use request-local SQLAlchemy scope and framework-independent envelopes. Feature gates, game ACL, librarian checks, CSRF, schema validation, filter-tree validation, ownership checks, and HEAD behavior remain covered. Fake adapters were used; focused VR tests passed 2/2 and saved-filter tests passed 4/4. P17 remains active pending other app-context adapters and primary Flask/WSGI retirement. + +**Batch refresh and stock-avatar native scope, 2026-10-14:** batch freshness and artwork-refresh routes plus stock-avatar selection now resolve identity and ORM work in request-local SQLAlchemy scopes. Their DB-free regressions reject Flask app-context use while preserving ACL, stale checks, librarian permission, CSRF, result envelopes and response statuses. Focused route checks passed 3/3 with fake adapters; no refresh work or avatar write ran. + +**Member preferences native scope, 2026-10-14:** GET/HEAD/PUT `/api/account/preferences` now runs without Flask app context, using request-local ORM scope and the shared response contract. Existing WTForms choice/validation behavior, cookie-session auth, CSRF, preference normalization, and session refresh remain. The focused preference read/write checks passed 2/2 with the route persistence adapter; JIN-07 remains active. + +**Avatar upload native scope, 2026-10-14:** POST `/api/account/avatar` resolves authentication, CSRF and ORM work without Flask app context. Multipart bounds, upload validation, configured storage adapter, transaction/rollback behavior, API envelopes, and session refresh remain. The focused DB-free upload route check passed 1/1 with the storage adapter replaced; no file was written. + +**Companion client and lifecycle native scope, 2026-10-14:** capabilities, heartbeat, command enqueue/ack/nack, and lifecycle read/write routes now use request-local ORM scope and framework-independent envelopes. Bearer scopes, browser CSRF, game ACL, command ownership, lifecycle validation, session refresh, and HEAD responses remain. Focused DB-free route tests passed 7/7 with Flask app-context creation disabled; no real client presence or command records were created. + +**Acquire readiness native scope, 2026-10-14:** `/api/acquire/status` now reads configuration explicitly and uses request-local SQLAlchemy scope for indexer and debrid readiness state, with framework-independent JSON. Redirect/flash, session, HEAD, feature status and member-safe payload behavior remain. Focused native/ASGI checks passed 7/7; no indexer, debrid or other provider call ran. + +**P17 Attract Mode and setup-state dispatch, 2026-10-14:** `/api/attract-mode/settings` and `/api/attract-mode/user-override` now use FastAPI for anonymous and signed-session traffic. Per-user filter/autoplay overrides continue to merge with shared admin defaults, and writes retain CSRF, signed-session refresh, persistence, and audit behavior. `/api/setup/state` now uses FastAPI for anonymous and credential-bearing reads; setup-only settings still require an admin signed session, and Bearer tokens cannot expose them. No-store, security headers, and HEAD behavior remain. Focused native Attract Mode/setup/ASGI/SSE selection passed 48/48; the updated outer-ASGI dispatch file passed 32/32; legacy setup API contract tests passed 4/4. Compileall, API-envelope lint, and scoped whitespace checks passed. The existing Attract Mode Flask API contract suite also passed 25/25 after the native routes were mounted. The strict runtime gate remains active because Flask startup, WSGI fallback, legacy Flask modules, and Flask/Jinja dependencies remain. + +**P17 admin statistics native route, 2026-10-14:** `GET`/`HEAD /api/admin/statistics` now uses FastAPI with request-local ORM scope and the existing aggregate builder. Signed-session admin checks, admin Bearer scope, login redirect, shared success envelope, no-store policy, security headers, and HEAD behavior remain. Focused native statistics plus ASGI dispatch checks passed 34/34; the existing DB-backed admin statistics route contract passed 3/3. A fresh AST census reports 557 Python files under `oneirodex`, 220 importing Flask or a Flask extension, zero authored template files, zero direct Jinja imports, and zero Jinja render calls. JIN-07 remains active because Flask app construction, WSGI fallback, and legacy Flask routes/extensions remain. + +**P17 admin statistics and Identify search dispatch, 2026-10-14:** `/api/admin/statistics` and admin-only `/api/admin/games_search` now dispatch through FastAPI with request-local SQLAlchemy scope. The statistics aggregate envelope and admin gate remain; Identify search preserves its bare list contract, empty/oversize query behavior, 20-result limit, and admin-only `full_disk_path` projection. Signed-session admin checks and admin token scope are enforced. Focused native and outer-ASGI coverage passed 38/38; existing DB-backed Flask contract suites passed 12/12. Compileall, API-envelope lint and scoped whitespace checks passed. Current census: 560 Python files under `oneirodex`, 220 importing Flask or an extension, zero authored templates, zero direct Jinja imports and zero render calls. Strict JIN-07 remains active while Flask startup, WSGI fallback and remaining Flask route/extensions are present. + +**P17 command-palette suggestions native route, 2026-10-14:** `/api/palette/suggest` now dispatches through FastAPI and request-local SQLAlchemy scope. Signed-session member auth, login redirect, the existing 1–12 limit clamp, shared success envelope, ACL-filtered recently played/favorite suggestions, and HEAD behavior remain. Native and outer-ASGI checks passed 35/35; the existing DB-backed feature suite passed 5/5. Compileall, API-envelope lint, and scoped diff checks passed. Current AST census: 561 Python files under `oneirodex`, 220 importing Flask or extensions, zero template files, zero Jinja imports, and zero render calls. JIN-07 remains active while Flask startup, WSGI fallback, and remaining legacy route/extensions remain. + +**P17 metadata-provider configuration read, 2026-10-14:** `GET`/`HEAD /api/admin/integrations/metadata-providers` now dispatches through FastAPI and request-local SQLAlchemy scope with signed-session admin checks, admin token scope, the existing provider flags/notes envelope, and HEAD behavior. `PUT` remains on the compatibility route so the existing configuration write contract is not changed by this read migration. The combined focused native/ASGI admin and member read selection passed 40/40; existing DB-backed provider configuration contracts passed 8/8. Compileall, API-envelope lint, scoped diff checks passed. Current AST census: 562 Python files, 220 Flask/extension import files, zero templates, zero direct Jinja imports, and zero render calls. JIN-07 remains active. + +**P17 metadata-provider write path completed, 2026-10-14:** `PUT /api/admin/integrations/metadata-providers` now also dispatches through FastAPI, completing this route family. It validates the signed-session CSRF token before auth and persistence, preserves admin/token-scope checks, empty/invalid body errors, partial provider updates, and session-cookie refresh. The focused native and outer-ASGI family passed 42/42; the existing DB-backed Flask compatibility suite passed 8/8. Compileall, API-envelope lint and scoped whitespace checks passed. The prior inventory note saying PUT remained on Flask is superseded by this entry. + +**P17 licensed-catalog read routes, 2026-10-14:** member `GET`/`HEAD /api/licensed-catalog` and admin `GET`/`HEAD /api/licensed-catalog/status` now use FastAPI and request-local ORM scope. Member ACL-projected reports, region metadata, platform validation, admin status authorization, error envelopes and HEAD behavior remain. The IGDB refresh write/provider action stays on its compatibility route. Native/outer-ASGI checks passed 44/44; existing DB-backed licensed-catalog contracts passed 9/9. Compileall, API-envelope lint, and scoped diff checks passed. Current census: 563 Python files, 220 Flask/extension import files, zero templates, zero Jinja imports and zero render calls. JIN-07 remains active. + +**P17 licensed-catalog refresh native write, 2026-10-14:** admin `POST /api/licensed-catalog/refresh` now dispatches through FastAPI and request-local SQLAlchemy scope. It preserves signed-session/admin-token authorization, CSRF-before-auth, JSON and form input, required-platform validation, refresh audit, bad-gateway response/detail, and cookie refresh. The native adapter invokes the same IGDB cache refresh service through a worker thread; tests replaced the refresh action and audit sink, so no provider request ran. Focused native/outer-ASGI catalog/admin selection passed 46/46; existing DB-backed licensed-catalog contracts passed 9/9. Compileall, API-envelope lint and scoped diff checks passed. This supersedes the earlier note that refresh remained on the compatibility route. JIN-07 remains active. + +**P17 admin theme loading-icon read alias, 2026-10-14:** `GET`/`HEAD /api/admin/themes/loading-icons` now dispatches through the existing FastAPI loading-icon adapter. It preserves the legacy API envelope, admin session/token-scope gates, catalogue projection, security headers, and HEAD behavior. Direct plus outer-ASGI loading-icon checks passed 39/39; the existing DB-backed theme API compatibility suite passed 8/8. Compileall, API-envelope lint and scoped diff checks passed. + +**P17 metadata-search source catalogue, 2026-10-14:** `GET`/`HEAD /api/search_metadata/sources` now dispatches through FastAPI and request-local SQLAlchemy scope. Its bare JSON source list, API-key configured booleans/masked hints, Meta Quest mode flags, admin session/token-scope gate, and HEAD behavior remain. The provider capability builder is framework-independent and performs no provider search. Native/outer-ASGI checks passed 41/41; existing metadata-search tests passed 19/19. Compileall, API-envelope lint and scoped diff checks passed. Fresh census: 565 Python files, 220 Flask/extension import files, zero templates, direct Jinja imports, or render calls. JIN-07 remains active. + +**P17 admin metadata provider search, 2026-10-14:** `/api/search_metadata` now dispatches through FastAPI and request-local SQLAlchemy scope. Admin session/token-scope gates, provider aliases, default/capped limit, include-software parsing, ownership flags, configured-key indicators, source notes, and bare response projection remain. Provider searches stay behind the existing scraper adapters; tests injected the provider result and issued no outbound calls. Native/outer-ASGI checks passed 38/38; existing metadata-search compatibility tests passed 19/19. Compileall, API-envelope lint, scoped diff checks passed. Current census: 566 Python files, 220 Flask/extension import files, zero templates, Jinja imports, or render calls. JIN-07 remains active. + +**P17 admin theme inventory read, 2026-10-14:** `GET`/`HEAD /api/admin/themes` now dispatches through FastAPI and request-local SQLAlchemy scope. It preserves the theme metadata/default/active-theme payload, admin session/token-scope gate, shared success envelope, security headers, and HEAD behavior. Theme uploads and deletes remain on their existing Flask compatibility routes. Native/outer-ASGI checks passed 35/35; the existing DB-backed admin theme API suite passed 8/8. Compileall, API-envelope lint, and scoped diff checks passed. Current census: 567 Python files, 220 Flask/extension import files, zero templates, direct Jinja imports, or render calls. JIN-07 remains active. + +**P17 HLTB coverage statistics read, 2026-10-14:** `GET`/`HEAD /api/hltb/stats` now dispatches through FastAPI and request-local SQLAlchemy scope. It preserves the `stats` envelope and total/with/without/coverage fields, signed-session/admin-token authorization, login redirects, no-store and security headers, and HEAD behavior. Native route and integrated outer-ASGI checks passed 35/35 using an injected stats adapter; no provider calls or database writes ran. + +**P17 Arr configuration read/write, 2026-10-14:** `GET`/`HEAD /api/arr/config` migrated first; `PUT /api/arr/config` has since joined FastAPI with request-local SQLAlchemy scope. The shared projection exposes only configured flags, never secret values; writes retain CSRF-before-auth, URL validation, module/admin gates, and safe public output. Provider operations were migrated separately. + +**P17 custom chat emoji catalogue and delete, 2026-10-14:** `GET`/`HEAD /api/admin/chat-emoji` and `DELETE /api/admin/chat-emoji/` now use FastAPI with request-local ORM scope. Admin session/token checks, CSRF-before-auth, `no-store`, shared success/error envelopes, upload limits, extension list, slug validation, and generic storage errors remain. Upload stays on its existing route. The combined native/outer-ASGI selection with Arr config and HLTB stats passed 42/42 with one upstream Starlette/httpx deprecation warning. Delete tests used a fake adapter; no emoji files or database rows were modified. Compileall, API-envelope lint, and scoped diff checks passed. Current census: 570 Python files, 220 Flask/extension import files, zero templates, direct Jinja imports, or render calls. Flask startup/WSGI fallback, remaining legacy handlers/extensions, and Flask/Jinja dependencies remain; JIN-07 stays active. + +**P17 Arr indexer catalogue read, 2026-10-14:** `GET`/`HEAD /api/arr/indexers` now dispatches through FastAPI and request-local SQLAlchemy scope. It retains the bare indexer/preset payload and module/admin authorization; no provider call or write occurs on this read path. Native and outer-ASGI coverage passed 44/44 with the Arr config, chat emoji, and HLTB stats checks. Compileall, API-envelope lint, and scoped diff checks passed. Fresh census: 571 Python files, 220 Flask/extension imports, zero templates, direct Jinja imports, or render calls. Flask startup, WSGI fallback, remaining legacy routes/extensions, and Flask/Jinja dependencies continue to block strict JIN-07 completion. + +**P17 Arr module status read/write, 2026-10-14:** `GET`/`HEAD /api/arr/module` migrated first; `PUT /api/arr/module` has since joined FastAPI, preserving enabled/DB/env flags and availability while the module is off. The write checks CSRF before auth and commits only the module toggle. Current consolidated evidence is recorded with the later Arr migration entries; strict JIN-07 remains active. + +**P17 Arr indexer detail read, 2026-10-14:** `GET`/`HEAD /api/arr/indexers/` now uses the same FastAPI router and request-local SQLAlchemy scope as the list read. It preserves the single-item envelope, credential-redacted projection, admin/module checks, not-found response, and HEAD behavior. The combined native/outer-ASGI group passed 45/45 (one upstream Starlette/httpx warning). Indexer writes/search, the Flask startup bridge, other legacy routes/extensions, and Flask/Jinja dependencies remain open in JIN-07. + +**P17 HLTB coverage statistics read, 2026-10-14:** `GET`/`HEAD /api/hltb/stats` now dispatches through FastAPI and request-local SQLAlchemy scope. It preserves the `stats` envelope and total/with/without/coverage fields, signed-session/admin-token authorization, login redirects, no-store and security headers, and HEAD behavior. Native route and integrated outer-ASGI checks passed 35/35 using an injected stats adapter; no provider calls or database writes ran. Compileall, API-envelope lint, and `git diff --check` passed. Fresh census: 568 Python files, 220 Flask/extension import files, zero templates, direct Jinja imports, or render calls. Flask startup, WSGI fallback, remaining legacy route/extensions, and Flask/Jinja dependencies still block strict JIN-07 completion. + +**P17 SMTP settings read, 2026-10-14:** `GET`/`HEAD /api/admin/smtp/settings` now dispatches through FastAPI and request-local SQLAlchemy scope. The shared framework-independent settings projection preserves the `settings` envelope and defaults while exposing only whether the SMTP password is saved. Admin/session/token authorization, no-store, and HEAD behavior remain; PUT and connection testing stay on compatibility routes. Combined focused native/outer-ASGI checks passed 48/48 with one upstream Starlette/httpx deprecation warning; no SMTP connection was attempted. Census: 572 Python files, 220 Flask/extension imports, zero templates, direct Jinja imports, or render calls. JIN-07 remains active while Flask startup, WSGI, remaining legacy routes/extensions, and Flask/Jinja dependencies are present. + +**P17 IGDB settings read, 2026-10-14:** `GET`/`HEAD /api/admin/igdb/settings` now uses FastAPI and request-local SQLAlchemy scope. A shared framework-independent projection retains the settings envelope, client ID and test timestamp, and returns only a secret-presence flag. Admin/session/token authorization, no-store, and HEAD behavior remain; PUT and IGDB provider tests stay on compatibility routes. The combined DB-free native/outer-ASGI suite passed 51/51 with one upstream Starlette/httpx deprecation warning; no provider request ran. Census: 574 Python files, 220 Flask/extension imports, zero authored templates, direct Jinja imports, or render calls. JIN-07 remains active until Flask startup/WSGI, remaining route/extensions, and Flask/Jinja dependencies are retired. + +**P17 IGDB settings write, 2026-10-14:** `PUT /api/admin/igdb/settings` now dispatches through FastAPI as well. It checks Flask-WTF-compatible CSRF before auth/persistence, applies the shared input validation, retains a saved secret when the new value is blank, refreshes signed-session cookies when required, invalidates the settings cache, and returns only the public projection. Provider tests remain on compatibility routes. The combined DB-free native/outer-ASGI route selection passed 52/52 (one upstream Starlette/httpx deprecation warning); fake persistence was used and no provider call ran. JIN-07 remains active because Flask startup/WSGI, legacy routes/extensions, and Flask/Jinja dependencies remain. + +**P17 IGDB connection test action, 2026-10-14:** `POST /api/admin/igdb/test` now dispatches through FastAPI with CSRF-before-auth and admin/token-scope checks. It retains missing-credentials, bad-gateway, success and last-tested behavior, while keeping secrets out of responses. The existing provider adapter is injected in tests; no outbound IGDB request ran. Combined native/outer-ASGI settings-family checks pass after adding the route to the dispatcher. JIN-07 remains active because primary Flask startup/WSGI, remaining legacy routes/extensions and Flask/Jinja dependencies still need retirement. + +**P17 IGDB settings-family final verification, 2026-10-14:** the focused selection covering IGDB GET/PUT/test, SMTP GET/PUT, Arr status/config/indexer list/detail, chat emoji GET/DELETE, HLTB coverage, and outer-ASGI dispatch passed 54/54 (one upstream Starlette/httpx deprecation warning). Provider and SMTP actions were replaced by fakes; no external request or real settings write occurred. Compileall, API-envelope lint, scoped `git diff --check`, docs update, and hub page generation passed. Current census: 575 Python files, 220 importing Flask/extensions, zero authored templates, direct Jinja imports, or render calls. JIN-07 remains active because primary Flask construction/WSGI fallback, many legacy routes/extensions, and Flask/Jinja dependencies remain. + +**P17 SMTP settings write, 2026-10-14:** `PUT /api/admin/smtp/settings` now dispatches through FastAPI with request-local SQLAlchemy scope and the shared SMTP validator. It validates CSRF before auth/persistence, preserves blank-password retention, cache invalidation, the safe public response, generic persistence errors, and session refresh. SMTP connection tests remain on compatibility routes. The combined DB-free route/dispatch group passed 53/53 (one upstream Starlette/httpx deprecation warning) with fake persistence; no SMTP connection or settings write ran. JIN-07 remains active until Flask startup/WSGI, remaining routes/extensions, and Flask/Jinja dependencies are removed. + +**P17 SMTP connection test action, 2026-10-14:** `POST /api/admin/smtp/test` now uses FastAPI with CSRF-before-auth and admin/token-scope checks. It retains the disabled-settings rejection, safe success message stating that no email was sent, and generic gateway failure response. The SMTP tester is injected in tests; no network socket was opened or email sent. The combined native/outer-ASGI selection passed 55/55 (one upstream Starlette/httpx deprecation warning). JIN-07 remains active while primary Flask startup/WSGI, legacy routes/extensions and Flask/Jinja dependencies remain. + +**P17 Arr indexer mutations, 2026-10-14:** indexer create `POST /api/arr/indexers`, update `PUT`/`PATCH /api/arr/indexers/`, and delete `DELETE /api/arr/indexers/` dispatch through FastAPI with request-local SQLAlchemy scope. CSRF is checked before auth, the module/admin gates and created/saved/deleted/not-found contracts remain, and responses use the existing public view that omits API-key values. Focused native and outer-ASGI tests use fake mutation adapters; no indexer data or provider was changed. Strict JIN-07 remains open. + +**P17 Arr configuration and action APIs, 2026-10-14:** `GET`/`HEAD`/`PUT /api/arr/config`, `GET`/`HEAD`/`PUT /api/arr/module`, indexer CRUD/bulk/presets, search, download, and hardlink preview/apply now dispatch through FastAPI. Secret values remain masked; config writes retain URL validation and CSRF-before-auth, and the module toggle remains available when disabled. Search quality scoring/filtering, outbound URL validation and hardlink feature/path checks remain. The hardlink helper takes explicit app configuration for ASGI use. The consolidated DB-free native/outer-ASGI group passed 76/76 (one upstream Starlette/httpx deprecation warning); fakes prevented provider/download-client calls and hardlink application. A real preview used only temporary files. No indexer or library data changed. + +**P17 Quality Profiles API family, 2026-10-14:** list/HEAD, create, active selection, base save, detail, update/PATCH, delete and score actions now use FastAPI. Admin/session/token-scope checks, CSRF-before-auth, existing bare JSON payloads, 201 create status, validation errors and security headers remain. The native tests exposed and fixed HEAD returning the wrong payload after the write routes were added. The consolidated native/outer-ASGI selection passed 84/84 (one upstream Starlette/httpx deprecation warning) using fake persistence/scoring adapters; no profile database values changed. JIN-07 remains active pending primary Flask/WSGI retirement, other route migrations, and Flask/Jinja dependency removal. + +**P17 HLTB refresh actions, 2026-10-14:** `POST /api/hltb/refresh/` and `POST /api/hltb/bulk-refresh` now dispatch through FastAPI and request-local SQLAlchemy scope. CSRF-before-auth, admin/token-scope gates, disabled/missing/unmatched outcomes, the 1–200 bulk limit, and the existing count/error summary remain. Bulk provider exceptions are returned as a generic per-game failure. Fake adapters handled the native and outer-ASGI tests; no HLTB request or database update ran. The focused HLTB and dispatcher group passed 38/38 (one upstream Starlette/httpx warning); JIN-07 remains active. + +**P17 reference-set API family, 2026-10-14:** admin/member listing and multipart upload, both delete actions, `GET /api/set-completion`, `POST /api/reference-sets/rehash`, and the dry-run `POST /api/reference-sets/repair-preview` now dispatch through FastAPI with request-local SQLAlchemy scope. Existing access levels, response envelopes, validation, catalogue data, and CSRF-before-auth are retained; the admin listing stays `no-store`. Added pinned `python-multipart` because Starlette form parsing otherwise fails at runtime. The complete DB-free native/outer-ASGI group passed 68/68 (one upstream Starlette/httpx deprecation warning); all write/re-hash/preview adapters were faked so no catalogue, filesystem, or library data changed. JIN-07 remains active while Flask startup/WSGI, remaining legacy routes/extensions, and Flask/Jinja dependencies remain. + +**P17 Discover pin save, 2026-10-14:** `PUT /api/discover/pins` now dispatches through FastAPI alongside the existing native reads. Signed member sessions, CSRF validation, partial pins/hidden updates, pin cap, admin-forced-row protection, not-found details, response envelopes and security headers remain. Direct native and outer-ASGI coverage passed 23/23 with a fake persistence adapter; no member preferences were changed. Strict JIN-07 remains active pending the primary runtime replacement, remaining Flask compatibility routes/extensions, and Flask/Jinja dependency removal. + +**P17 admin theme inventory writes, 2026-10-14:** `POST /api/admin/themes` and `DELETE /api/admin/themes/` now dispatch through FastAPI alongside the existing inventory read. Admin/session/token-scope checks, CSRF-before-auth, filename/theme validation, size limits, cleanup on invalid uploaded names, response envelopes and session refresh are retained. The combined Discover/theme native and outer-ASGI selection passed 27/27 using fake file/persistence adapters; no theme files or member preferences changed. Strict JIN-07 remains active pending primary Flask/WSGI replacement, remaining compatibility routes/extensions, and Flask/Jinja dependency removal. + +**P17 playtime share card, 2026-10-14:** `GET /api/playtime/share/.svg` now dispatches through FastAPI alongside native playtime reads and session actions. It preserves signed member auth, game ACL checks, progress totals, the SVG content type, missing-game response and security headers. The native ASGI playtime selection passed 6/6 with a fake adapter and no Flask app context; strict JIN-07 remains active pending primary runtime replacement, remaining compatibility routes/extensions, and Flask/Jinja dependency removal. + +**P17 device config and probe actions, 2026-10-14:** challenge-solver and ambient-lighting config reads/writes and explicit test actions now dispatch through FastAPI alongside status reads. Admin/session/token-scope authorization, CSRF-before-auth, bare read/test payloads, enveloped saves, existing input validation, write-only secret retention, and explicit probe behavior remain. The direct and outer-ASGI device selection passed 10/10 using fake adapters; no settings were written and no bridge/solver network call ran. JIN-07 remains active pending primary Flask/WSGI replacement, remaining compatibility routes/extensions, and Flask/Jinja dependency removal. + +**P17 AI configuration read/write, 2026-10-14:** `GET`/`HEAD`/`PUT /api/ai/config` now dispatches through FastAPI alongside the existing AI status read. It preserves the bare config projection, recognized-field validation, enveloped save response, admin/token-scope checks, CSRF-before-auth, and explicit app configuration for the shared persistence helper. Direct and outer-ASGI tests passed 8/8 with a fake persistence adapter and no Ollama call. JIN-07 remains active pending primary Flask/WSGI replacement, remaining compatibility routes/extensions, and Flask/Jinja dependency removal. + +**P17 admin notification-bus test action, 2026-10-14:** `POST /api/admin/notify-bus/test` now dispatches through FastAPI beside the native notification inbox/preferences APIs. Admin and token-scope checks, CSRF-before-auth, configured-endpoint rejection, response envelope, session handling, and the existing explicit test notification are preserved. Outer-ASGI tests passed with fake status/send adapters; no notification was sent. JIN-07 remains active pending primary Flask/WSGI replacement, remaining compatibility routes/extensions, and Flask/Jinja dependency removal. + +**P17 AI assistant action APIs, 2026-10-14:** `/api/ai/triage`, `/api/ai/doctor-notes`, and `/api/ai/apply-triage` now dispatch through FastAPI with request-local SQLAlchemy, completing the AI action family alongside native status/config. Admin/token authorization, CSRF-before-auth, unmatched-folder/game context lookup, validation/error mapping, and the explicit auto-apply gate plus 200/201 statuses remain. Direct and outer-ASGI AI coverage passed 10/10 using fake adapters; no Ollama call or game rename ran. JIN-07 remains active pending primary Flask/WSGI replacement, remaining compatibility routes/extensions, and Flask/Jinja dependency removal. + +**P17 arcade input-family write, 2026-10-14:** `PATCH /api/games//input_family` now dispatches through FastAPI and request-local database scope. It preserves the librarian role and admin token-scope gates, CSRF-before-auth, game ACL, nullable closed vocabulary, legacy response envelope and generic persistence error. The focused assists/input-family outer-ASGI selection passed 4/4; compileall, API-envelope lint and scoped diff check passed. Strict JIN-07 remains active pending primary Flask/WSGI retirement, remaining compatibility routes/extensions and Flask/Jinja dependency removal. + +**P17 remaining game reads and admin freshness refresh, 2026-10-14:** `GET`/`HEAD /api/get_next_custom_igdb_id` and `POST /api/admin/freshness/refresh` now dispatch through FastAPI. The ID helper retains the signed-in bare payload and HEAD response; bulk freshness retains admin/token-scope authorization, CSRF-before-auth, stale-only filtering, limit/library selection, per-game results and commit handling. Focused read/input-family/assists coverage passed 5/5, and admin plus batch freshness coverage passed 2/2. Compileall, API-envelope lint, and scoped diff checks passed. Fake adapters were used, so no game metadata changed. Strict JIN-07 remains active pending primary Flask/WSGI retirement, remaining compatibility routes/extensions and Flask/Jinja dependency removal. + +**P17 move-game write, 2026-10-14:** `POST /api/move_game_to_library` now runs on FastAPI with request-local SQLAlchemy scope. It retains CSRF-before-auth, librarian/admin-token authorization, source-game and destination-library existence/access checks, the success envelope, commit/rollback handling, and audit-event logging. The focused move/input-family/admin-freshness selection passed 3/3; compileall, API-envelope lint, and scoped diff check passed using fake game/library adapters. Strict JIN-07 remains active pending primary Flask/WSGI retirement, remaining compatibility routes/extensions, and Flask/Jinja dependency removal. + +**P17 event-bus test action, 2026-10-14:** `POST /api/events/publish_test` now dispatches through FastAPI, retaining browser CSRF, signed-session and admin-token checks, admin authorization, the bare published-event payload, and security/session headers. The focused event-bus/notification test selection passed 2/2 with an injected event adapter and no event-bus side effect. Compileall, API-envelope lint and scoped diff check passed. Strict JIN-07 remains active pending primary Flask/WSGI retirement, remaining compatibility routes/extensions and Flask/Jinja dependency removal. + +**P17 member library catalogue read, 2026-10-14:** `GET`/`HEAD /api/get_libraries` now dispatches through FastAPI with request-local SQLAlchemy. It retains per-member library filtering, game/unmatched counts, platform release totals, image/watch projections, signed-session redirects, and HEAD behavior. The focused library/dispatch regressions passed 6/6 with an injected catalogue adapter; compileall, API-envelope lint and scoped diff check passed. Strict JIN-07 remains active pending the remaining library APIs, primary Flask/WSGI retirement, compatibility routes/extensions and Flask/Jinja dependency removal. + +**P17 library detail and watch reads, 2026-10-14:** `GET`/`HEAD /api/library/` and `GET`/`HEAD /api/library//watch` now dispatch through FastAPI. The handlers preserve ACL-before-existence checks, safe not-found/forbidden envelopes, public library fields and watch projections. The combined library read selection passed 7/7; compileall, API-envelope lint and scoped diff check passed with an injected adapter. Library writes and remaining admin library operations remain on the migration list; strict JIN-07 is still active. + +**P17 library watch write, 2026-10-14:** `PUT /api/library//watch` now dispatches through FastAPI and request-local SQLAlchemy. CSRF-before-auth, ACL-before-lookup, librarian/admin-token checks, required nullable tri-state input, bare watch response and security/session headers remain. Focused library watch/detail/catalogue coverage passed 3/3 using a fake persistence adapter; compileall, API-envelope lint and scoped diff check passed. The remaining library write/admin endpoints and strict JIN-07 gate remain open. + +**P17 library reorder action, 2026-10-14:** `POST /api/reorder_libraries` now dispatches through FastAPI. Admin/session/token-scope authorization, CSRF-before-auth, ordered display-index persistence, legacy `status: success` body field and generic rollback error response remain. Focused reorder/watch/detail regressions passed 3/3 with fake persistence; compileall, API-envelope lint and scoped diff check passed. Other library writes/admin routes and strict JIN-07 remain open. + +**P17 admin library detail read, 2026-10-14:** `GET`/`HEAD /api/admin/libraries/` now dispatches through FastAPI with admin/session/token-scope authorization and the existing enveloped public library fields. Success and non-admin refusal coverage passed 3/3 alongside the library detail/watch routes; compileall, API-envelope lint, and scoped diff check passed. Admin library writes and other batch operations remain open under JIN-07. + +**P17 library batch edit action, 2026-10-14:** `POST /api/admin/libraries/batch/edit` now dispatches through FastAPI with request-local SQLAlchemy. Admin/token-scope authorization, CSRF-before-auth, the 100-library cap, shared and per-item overrides, partial outcomes, scan-depth/platform/watch/group validation, and success/error result summaries are retained. The focused batch-edit/reorder/watch/admin-detail group passed 4/4 using a fake persistence adapter; compileall, API-envelope lint and scoped diff check passed. Other library write and scan/delete actions remain open, so JIN-07 stays active. + +**P17 member chat emoji read, 2026-10-14:** `GET`/`HEAD /api/chat/emoji` now dispatches through FastAPI with signed-session authentication, `read:social` token scope, the legacy bare `fixed/custom/max_custom` payload and HEAD behavior. The focused member emoji and adjacent notification regression selection passed 2/2 with an injected emoji adapter; compileall, API-envelope lint and scoped diff check passed. Chat messages, channels, attachments, and other remaining APIs are still on the compatibility route list. + +**P17 member chat channels and spaces, 2026-10-14:** `GET`/`HEAD /api/chat/channels`, `POST /api/chat/channels`, `GET`/`HEAD /api/chat/spaces`, `GET`/`HEAD /api/chat/channels//messages`, and `GET`/`HEAD /api/chat/search` now dispatch through FastAPI. Existing signed-session/`read:social` behavior, CSRF-before-auth and `write:social` for channel creation, room alias, member-filtered space/channel projections, opaque inaccessible-channel 404, message read marker, bounded integer query limits (malformed values return 400), envelopes, HEAD handling, and security/session headers are preserved. After the adjacent channel-action slice, the full native browse file passed 190/190; compileall, API-envelope lint and `git diff --check` passed. Adapters were replaced with fakes in route tests; no channel or message state was changed. Message/reaction writes, attachments and remaining space APIs still use the Flask compatibility routes, so strict JIN-07 remains active. + +**P17 pagination and error redaction corrections, 2026-10-15:** Shared chat limit parsing now rejects malformed values as a client error in both Flask compatibility and native FastAPI read routes; valid values retain the same defaults and bounds. The Flask activity feed now uses the same safe default for malformed limits as native ASGI. Artwork application/search failures no longer expose upstream exception text, and hardlink/proposal-cleanup failures no longer expose raw filesystem errors or paths. Focused regressions cover malformed pagination and these error boundaries. + +**P17 chat channel actions, 2026-10-14:** Native FastAPI now handles `POST /api/chat/channels//mute`, `/archive`, `/leave`, and `/api/chat/dm`, retaining CSRF-before-auth, `write:social` token scope, model validation, permission/error contracts and session/security headers. The focused chat route selection passed 9/9 and the full native browse file passed 190/190; compileall, API-envelope lint and `git diff --check` passed. Adapters were faked so no channels or memberships were changed. Message writes/reactions, attachment upload/download, custom emoji writes, remaining space APIs and primary Flask/WSGI retirement remain open; strict JIN-07 stays active. + +**P17 chat message writes, 2026-10-14:** `POST /api/chat/channels//messages` and `POST /api/chat/messages//reactions` now dispatch through FastAPI. They preserve CSRF-before-auth, `write:social`, opaque missing-channel/message handling, parent and attachment-ID validation, message/attachment serialization, reaction validation and the legacy 201/envelope shapes. The focused chat selection passed 11/11 and the full native browse suite passed 192/192; compileall, API-envelope lint and `git diff --check` passed. Fake adapters prevented database or notification side effects. Attachment upload, custom emoji writes, remaining space APIs and primary Flask/WSGI retirement still block strict JIN-07. + +**P17 chat attachment download, 2026-10-14:** `GET`/`HEAD /api/chat/attachments/` now runs through FastAPI. The data adapter enforces pending-uploader ownership and channel visibility, resolves the file within the attachment directory, and retains the private cache and inline-image/download behavior. Its focused test covers content, disposition and not-found handling with temporary files. With the member-space reads included, the full native browse suite passed 195/195; compileall, API-envelope lint and `git diff --check` also passed. Uploads, custom emoji writes, remaining space APIs and the Flask/WSGI runtime still remain under JIN-07. + +**P17 chat space member and invite reads, 2026-10-14:** `GET`/`HEAD /api/chat/spaces//members` and `/invites` now dispatch through FastAPI. The member endpoint retains space membership visibility and the shared success envelope; the invite endpoint retains the `read:social` token gate, admin-only access, legacy redirect for non-admin members, and envelope. Fake adapters confirmed both payloads and the admin redirect without DB writes. The focused chat tests passed 14/14; the full native browse file passed 195/195, along with API-envelope lint and diff checks. Remaining space writes/voice-room APIs and the strict Flask/Jinja runtime gate remain open. + +**P17 chat space actions, 2026-10-14:** The FastAPI chat router now also serves space creation, channel creation, member add/remove, invite creation/revocation/redemption, and voice-room lookup. CSRF-before-auth, `read:social`/`write:social`, admin token scope, admin redirects, closed visibility options, invite expiry parsing, one-time token return, child-safe voice checks and shared envelopes are retained. The new create-space/channel/member/invite/voice adapters passed focused fake-adapter tests; the full native browse file passed 200/200. Compileall, API-envelope lint and `git diff --check` passed. No space, invite, or membership state was changed. Chat attachment upload and custom emoji writes remain on Flask; the primary runtime/WSGI bridge and Flask/Jinja dependencies still block JIN-07. + +**P17 chat API cutover, 2026-10-14:** The remaining chat multipart writes now dispatch through FastAPI: member attachment upload, member custom-emoji upload/delete, and the admin UI's custom-emoji upload endpoint. Storage helpers accept explicit upload-folder/quota settings so these APIs do not need Flask `current_app`; request uploads are bounded and their spooled files are closed. Focused multipart selections passed with `ResourceWarning` treated as an error. The full native browse file passed 203/203; compileall, API-envelope lint and `git diff --check` passed. Fake adapters were used, so no DB or file storage was modified. The chat API routes are now native; page shells and the outer runtime still initialize Flask, and the broader Flask/Jinja dependency gate is still open. + +**P17 detail-layout API family, 2026-10-14:** The eight `/api/layouts/detail` endpoints (shared admin layout, member override and named presets) now dispatch through FastAPI and request-local database scope. Existing payloads, validation, per-member isolation, admin authorization, CSRF-before-auth and preset ownership 404 behavior remain. The new outer-ASGI fake-adapter selection passed; compileall, API-envelope lint and `git diff --check` passed. No layout or preset data was changed. Strict JIN-07 remains active while the remaining Flask route tree, WSGI bridge, extensions and transitive Jinja dependency are still present. + +**P17 multipart upload limit follow-up, 2026-10-14:** The chat custom-emoji and attachment upload routes now cap the full streamed multipart body before Starlette form parsing, in addition to per-file limits; over-limit bodies receive the shared 413 envelope. Focused multipart tests passed with `ResourceWarning` treated as an error, including an oversized-body check, and the storage-root helper tests prove explicit roots work without Flask app context. Compileall, API-envelope lint and `git diff --check` passed. The previously recorded 203-test full chat/API regression remains the last full-file run; this follow-up was verified with focused tests. + +**P17 combined native-route regression, 2026-10-14:** After the detail-layout API family and bounded multipart upload follow-up, `tests/test_asgi_native_browse.py` passed 205/205. Python compileall, API-envelope lint (10 known call sites, one file below baseline), and `git diff --check` passed. This verifies the integrated native route surface covered by that suite; it does not establish all 653 legacy Flask URL rules or primary-runtime parity. JIN-07 remains active pending remaining route families, no-Flask startup/WSGI retirement, compatibility extensions and Flask/Jinja dependency removal. + +**P17 ownership metadata and connection reads, 2026-10-14:** `GET`/`HEAD /api/ownership/providers` and `/api/ownership/connections` now dispatch through FastAPI. Capability metadata retains operator opt-in policy; connection status remains sourced from stored member records. Both require the existing signed-in identity, preserve the success envelope, security headers, session refresh and HEAD behavior, and make no provider network calls. The outer-ASGI fake-adapter test passed; `tests/test_asgi_native_browse.py` passed 206/206. Compileall, API-envelope lint and `git diff --check` passed. JIN-07 remains active because the main lifespan and WSGI fallback still initialize Flask, and remaining legacy API/page route families and framework dependencies remain. + +**P17 ownership review API cutover, 2026-10-14:** The title review list, candidate lookup, match, and undo APIs now dispatch through FastAPI. Existing cursor/status validation, owner-scoped service calls, visible-library candidate filtering, optimistic revision checks, rollback on `ReviewError`, shared envelopes, and CSRF-before-auth are preserved. Fake-adapter outer-ASGI tests passed for list/candidates validation and match/undo success, missing CSRF, and conflict rollback. `tests/test_asgi_native_browse.py` passed 208/208; compileall, API-envelope lint, and `git diff --check` passed. Ownership credential, CSV import, and store-sync actions remain on compatibility routes. Primary Flask/WSGI retirement and dependency removal remain, so JIN-07 is active. + +**P17 ownership sync cancellation, 2026-10-14:** `POST /api/ownership/{store}/sync/cancel` now uses FastAPI with CSRF-before-auth, live-store validation, existing cancellability checks, and member-safe job/error projections. Focused ownership tests passed 4/4 (including CSRF and HTTP 202); the complete `tests/test_asgi_native_browse.py` file passed 209/209. Compileall, API-envelope lint, and `git diff --check` passed. No provider was contacted. Remaining ownership credential/CSV/sync routes and the broader Flask/WSGI migration remain open; JIN-07 is active. + +**P17 ownership review and emulator read APIs, 2026-10-14:** Ownership provider metadata, connected-account status, summary, paged owned-title review, candidate lookup, match/undo, and sync cancellation now dispatch through FastAPI. Emulator catalogue and per-platform reads also dispatch natively with session checks and the legacy generic unsupported-platform response. The latest `tests/test_asgi_native_browse.py` run passed 210/210; focused route tests, compileall, API-envelope lint, and `git diff --check` passed. Tests used fake adapters; no ownership records, configuration, providers, or library data changed. Ownership credential/CSV/live sync and other Flask route families, WSGI startup/fallback, and Flask/Jinja dependency retirement remain open. + +**P17 emulator settings API cutover, 2026-10-14:** `/api/emulator-profiles` GET/HEAD and POST/PUT now dispatch through FastAPI. The route preserves member authentication, administrator-only access, token admin-scope checks, flat-map and nested `profiles` inputs, existing validation/storage helpers, and CSRF-before-auth on writes. Fake-adapter tests cover member denial, admin read, missing CSRF, and save behavior. `tests/test_asgi_native_browse.py` passed 211/211; compileall, API-envelope lint, and `git diff --check` passed. No emulator profile settings were modified. Strict JIN-07 remains active; all other compatibility route families and the Flask/WSGI runtime gate remain. + +**P17 emulator and browser-player settings cutover, 2026-10-14:** `/api/emulators`, per-platform emulator choices, `/api/emulator-profiles` GET/HEAD/POST/PUT, and `/api/browser-player-settings` GET/POST/PUT now dispatch through FastAPI. Admin settings preserve role and token-scope authorization, browser CSRF, legacy input forms, existing validation/storage helpers, response envelopes, and safe errors. Focused tests cover supported/unsupported platforms, anonymous/member/admin access, CSRF, invalid JSON, and save behavior. The integrated native route suite passed 212/212; compileall, API-envelope lint, and `git diff --check` passed. Fake data adapters prevented settings writes or library access. JIN-07 remains active pending remaining Flask route families and primary runtime/dependency retirement. + +**P17 system API cutover, 2026-10-14:** `/api/check_path_availability` and `/api/file_types/{allowed|ignored}` now dispatch through FastAPI. Path checks retain signed-in access, allowlisted-root validation, boolean-only existence results and generic safe failures; the shared validator now accepts an explicit logger so malformed allowlist entries do not need Flask's `current_app`. File-type list/create/update/delete keeps the legacy flat response shapes, validation, admin/token scope checks, CSRF-before-auth, duplicate/not-found handling and rollback behavior. Outer-ASGI tests use fake adapters and prove rejected paths never reach existence checks or file-type persistence. `tests/test_asgi_native_browse.py` passed 215/215; compileall, API-envelope lint and `git diff --check` passed. No file types or filesystem data were changed. JIN-07 remains active: the full legacy route tree, primary Flask/WSGI runtime and transitive Jinja dependency still require migration. + +**P17 browse and ownership action follow-up, 2026-10-14:** Admin library-root inventory and folder browsing (`/api/library_roots`, `/api/browse_folders_ss`) now dispatch through FastAPI with signed-session/admin checks, root selection, allowlisted-path validation, legacy response shapes and GET/HEAD behavior. Ownership connect/disconnect, per-store sync, sync-all and CSV import actions also use the native router with CSRF-before-auth, store validation, token scope and existing payload aliases; provider and storage calls are isolated behind adapters. Dedicated adapter tests cover browser path traversal, auth, HEAD and ownership body/CSRF rules. The integrated native route suite plus new focused tests passed 229/229; compileall, API-envelope lint and scoped `git diff --check` passed. Tests used fakes, so no provider, ownership, filesystem or library data changed. JIN-07 remains active while other legacy route families, the Flask/WSGI runtime, and Flask/Jinja dependencies remain. + +**P17 emulator, server, and mod APIs, 2026-10-14:** Emulator cheat/save/PC-cheat and BIOS routes, the household game-server registry, and the game-mod/profile/catalogue APIs now dispatch through FastAPI. The migration keeps library ACLs, role and token scopes, CSRF-before-auth, member-safe server projections, game-mod feature gates, the existing validation and response contracts, download behavior, and the admin firmware workflow. Emulator multipart bodies are size-limited before parsing and parsed forms close on success and error paths. The adapters are faked in tests, including the firmware install/probe/provider boundaries, so no files, server configuration, or game data were changed. The combined native-route and focused batch passed 252/252; emulator uploads also passed with `ResourceWarning` treated as an error. Compileall, API-envelope lint and scoped `git diff --check` passed. JIN-07 remains active because the Flask/WSGI startup path, other legacy routes and Flask/Jinja dependencies remain. + +**P17 unmatched-folder read API cutover, 2026-10-14:** `GET`/`HEAD` list, duplicate candidates, fix logs, and CSV/JSON export for `/api/unmatched_folders` now dispatch through FastAPI. The list keeps first-value query parsing, filter aliases, transform toggles, pagination caps, region filtering and post-filter totals; duplicate rows preserve stale-scope behavior, and export keeps filename/column contracts while ignoring pagination. Admin/session and admin-token checks, redirects, security headers and HEAD behavior remain. Fake adapters avoid DB reads and disk changes. The integrated native-route batch passed 259/259, including the new outer-dispatch check; compileall, API-envelope lint and scoped `git diff --check` passed. Unmatched-folder mutation APIs and the overall Flask/WSGI/Jinja runtime gate remain open. + + +**P17 unmatched-folder mutation API cutover, 2026-10-14:** PATCH/POST soft naming, batch clear/mark/fix/amend, and per-row fix/mark/retry/place-update now dispatch through FastAPI. Existing admin/librarian and admin-token scopes, CSRF-before-auth, request caps, response envelopes, audit outcomes, path checks, and guarded update placement remain. Fake adapters covered all direct and outer-ASGI route paths; no database, scan, provider, or filesystem mutation ran. The combined native unmatched, emulator, server, game-mod, browse, ownership, and dispatcher selection passed 266/266. Compileall, API-envelope lint, and scoped `git diff --check` passed. Strict JIN-07 remains active pending remaining route parity and removal of Flask startup/WSGI/dependencies. + + +**P17 library-tools preview and maintenance follow-up, 2026-10-14:** Manual-identify preview, leaf-library proposals/import preview, proposals listing/root scan, and doctor dry-run now dispatch through FastAPI. These read and preview flows preserve admin/token authorization, CSRF-before-write parsing, allowlisted path checks, legacy response shapes, bounded upload parsing and cleanup. Unmatched duplicate reclassification, suggested-kind backfill, bad-match reason catalogue and feedback writes also now use FastAPI with their admin/librarian gates and CSRF order. Fake adapters prevented database, scan, provider, and filesystem changes. Native module tests passed 8/8; outer-ASGI dispatch tests for both families passed 2/2; 160 other selected route-dispatch regressions passed. Compileall, API-envelope lint and scoped `git diff --check` passed. Remaining library-tools mutation/queue endpoints and the overall Flask/WSGI runtime gate remain open. + + +**P17 library-tools write subset, 2026-10-14:** `POST /api/library_tools/rename/preview`, `/proposals/approve`, and `/doctor/write_proposals` now dispatch through FastAPI with admin/session/token checks, CSRF-before-body handling, and adapter boundaries for filesystem/database effects. Error responses avoid exposing raw local paths. Native library-tools and unmatched-feedback tests passed 12/12 with `ResourceWarning` treated as an error; the outer-ASGI library-preview/rename and unmatched-feedback dispatch checks passed 2/2. Compileall, API-envelope lint, and scoped `git diff --check` passed. This entry records the first write subset at that checkpoint; a later 2026-10-14 entry below records additional library-tools writes. Strict JIN-07 stays active. + + +**P17 admin library creation and batch actions, 2026-10-14:** `POST /api/admin/libraries`, `/batch/scan`, and `/batch/delete` now dispatch through FastAPI. Admin/session/token checks, CSRF-before-auth, create validation and bounded image parsing, batch caps, exact-name/force deletion confirmation, queue responses and progress hints remain. Database, scan, deletion and audit effects are behind adapters; native tests and outer-ASGI dispatch used fakes only. The focused adapter tests passed 4/4; the outer-ASGI library-delete route check passed, and compileall, API-envelope lint and scoped diff check passed. P17 remains active for outstanding library-tools routes and Flask/WSGI retirement. + + +**P17 manual-identify queue, support reads, and theme-font catalogue, 2026-10-14:** `POST /api/library_tools/manual_identify/list`, `GET`/`HEAD /api/support/tickets[/]`, and `GET`/`HEAD /api/theme/fonts` now dispatch through FastAPI. The manual scan route keeps schema validation, admin/token/CSRF gates, safe-root checks, idle preview session state, busy inspect/queue/force behavior, and no-store headers. Support tickets preserve browser-session-only access and owner/admin visibility; theme fonts preserve the authenticated catalogue shape. Direct native tests passed 20/20 (one upstream Starlette/httpx deprecation warning), the three outer-ASGI dispatch checks passed, and compileall/API-envelope lint/scoped diff check passed. The font catalogue still exposes the legacy `fonts_dir` field; review it during the promised security scrub. Strict JIN-07 remains active for other route families and Flask/WSGI retirement. + + +**P17 route-gap audit and consolidated slice check, 2026-10-14:** A source-level comparison estimated 316 overlapping legacy/native API method-path pairs out of 413 legacy pairs (76.5%); it does not prove ASGI mounting, method gates, or behavior. The comparison identified some already-migrated filter catalogs, theme/font reads, and support GET routes; these are now confirmed with native dispatch tests. Across the current bounded work, 36 focused native tests and 6 outer-ASGI dispatch checks passed, plus compileall, API-envelope lint and scoped diff checks. The strict no-Flask/Jinja runtime gate remains open. + + +**P17 provider catalogs and integrations inventory, 2026-10-14:** `GET`/`HEAD /api/providers`, `/api/patch-catalog/providers`, and `/api/admin/integrations/inventory` now dispatch through FastAPI. Admin/session/token-scope gates, legacy plain payloads, HEAD behavior and security headers remain; provider status adapters are faked in tests, so no outbound provider calls or catalog scans ran. Focused tests passed 10/10 (one upstream Starlette/httpx deprecation warning), outer-ASGI checks passed 2/2, and compileall/API-envelope lint/scoped diff check passed. Legacy `catalog_path` and `fonts_dir` fields still expose configured server-local paths to authorized users and are recorded for the follow-up security scrub. At that checkpoint, provider search/mutation and integration settings routes remained; the later follow-up below records several additional migrations. + +**P17 launcher import and artwork apply mutations, 2026-10-14:** `POST /api/imports/playnite`, `POST /api/imports/launcher`, and `POST /api/games/{game_uuid}/artwork/steamgriddb` now dispatch through FastAPI. Import uploads remain bounded and feature-gated; artwork retains CSRF-before-auth, admin and token-scope checks, typed request validation, provider/kind restrictions, and the existing artwork service behind an adapter. Direct mutation tests passed 8/8, including launcher and support mutation regressions; focused outer-ASGI launcher/artwork checks passed 2/2. Compileall, API-envelope lint, and scoped `git diff --check` passed. The integrated 249-test ASGI selection passed 248 tests with one test-only whitespace expectation mismatch; the corrected launcher assertion passed in the focused rerun. P17 remains active for remaining route families and retirement of Flask startup, WSGI dispatch, and framework dependencies. + +**P17 admin attract-mode settings API, 2026-10-14:** `GET`/`HEAD`/`PUT /api/admin/attract-mode/settings` now dispatches through FastAPI. Admin and admin-token scope checks, CSRF-before-auth on writes, strict `AttractModeBody` validation, filter-option reads, no-store/security headers, and audit/cache effects remain. Direct adapter tests passed 3/3 and the outer-ASGI save check passed 1/1; compileall, API-envelope lint, and scoped `git diff --check` passed. Remaining admin configuration and other legacy route families, plus Flask startup/WSGI/dependency retirement, keep JIN-07 active. + +**P17 admin feature and challenge-solver settings APIs, 2026-10-14:** `GET`/`HEAD`/`PUT /api/admin/features/settings`, `PUT /api/admin/features/challenge`, and `POST /api/admin/features/challenge/test` now dispatch through FastAPI. The seven DB preference fields remain strictly typed, process-level flags remain read-only, challenge settings keep their provider/URL/tier validation, probe results are projected to safe fields, and writes retain CSRF-before-auth plus admin token-scope checks. Combined direct attract/features tests passed 5/5; focused outer-ASGI admin-settings dispatch checks passed 2/2. Compileall, API-envelope lint, and scoped `git diff --check` passed. Remaining admin APIs and primary Flask/WSGI/dependency retirement keep JIN-07 active. + +**P17 admin scan-filter catalogue, 2026-10-14:** `GET`/`HEAD`/`POST /api/admin/scan-filters` and `DELETE /api/admin/scan-filters/{filter_id}` now dispatch through FastAPI. Filter kind/prefix rules, regex compilation, case-sensitive name behavior, admin token scope, CSRF-before-auth, response projections, and safe persistence errors remain. Direct adapter tests passed 2/2 and the outer-ASGI create check passed 1/1; compileall and scoped `git diff --check` passed. Other legacy route families and Flask/WSGI/dependency retirement remain open. + +**P17 admin plugin catalogue reads, 2026-10-14:** `GET`/`HEAD /api/plugins` and `/api/plugins/{plugin_id}` now dispatch through FastAPI, preserving admin/session/token-scope authorization, category filtering, legacy plain catalogue payload, not-found envelope, security headers, and HEAD responses. Direct fake-adapter tests passed 2/2 and the outer-ASGI catalog dispatch passed 1/1; compileall and scoped `git diff --check` passed. Other legacy route families and Flask/WSGI/dependency retirement remain open. + +**P17 single-library admin update, 2026-10-14:** `PUT /api/admin/libraries/{library_uuid}` now dispatches through FastAPI for JSON and multipart editor submissions. Admin/token checks, CSRF-before-auth, platform/depth/watch validation, bounded image upload and decode checks, image conversion, response projection, and audit event remain. `tests/test_native_library_admin_mutations.py` passed 5/5 and the outer-ASGI update check passed 1/1; compileall, API-envelope lint, and scoped diff checks passed. Remaining scan, auth, settings, and operational route families plus Flask/WSGI/dependency retirement remain active work. + + +**P17 single-library admin scan, 2026-10-14:** `POST /api/admin/libraries/scan` now dispatches through FastAPI. Admin/token authorization, CSRF-before-auth, bounded JSON/form parsing, configured-root and safe-path checks, scan-mode resolution, queue/force policy, schedule normalization, HLTB options, and scan flags remain adapter-backed. Direct tests passed 1/1 and the outer-ASGI dispatch test passed 1/1; compileall and API-envelope lint passed. Strict JIN-07 remains active for remaining route families and Flask/WSGI startup/dependency retirement. + + +**P17 admin refresh-all scan action, 2026-10-14:** `POST /api/admin/libraries/refresh_all` now dispatches through FastAPI. FIFO enqueueing, pre-enqueue busy detection and idle promotion, form/query/JSON force policy, and the sequential force worker remain behind the library scan adapter. The legacy ordering was retained so an idle queue still promotes its first job. Direct route tests and outer-ASGI dispatch passed (3 selected tests across the neighboring single-scan checks); compileall, API-envelope lint, and `git diff --check` passed. JIN-07 remains active for remaining Flask routes and runtime retirement. + + +**P17 library route-family regression, 2026-10-14:** The focused library reads/watch, batch-operation, admin-mutation, and outer-ASGI selection passed 60/60 (`232 deselected`). It includes the single-library scan and refresh-all routes alongside listing/detail, reorder, watch writes, batch edit/delete, and batch scan coverage. This does not validate the rest of the 653 legacy URL rules or the primary-runtime gate. + + +**P17 scan-job status read, 2026-10-14:** `GET`/`HEAD /api/scan_jobs_status` now dispatches through FastAPI with the legacy raw-array payload, status/library/name filters, queue position, timing/ETA/stalled fields, and best-effort idle queue drain. Admin/token checks, session refresh, and HEAD response behavior are preserved. Outer-ASGI dispatch plus `tests/test_scan_job_timing.py` passed 13/13; compileall and API-envelope lint passed. The ASGI bootstrap now binds the existing Flask-Login user loader explicitly instead of referencing an undefined name. Other route families and Flask/WSGI retirement remain open. + + +**P17 scan status and desktop path handoff, 2026-10-14:** The scan dashboard read (`GET`/`HEAD /api/scan_jobs_status`) and admin desktop handoff (`GET /api/path/open`) now dispatch through FastAPI. The path endpoint retains its query aliases, 2,048-character cap, admin-only gate, existence/directory metadata, and desktop-only handoff semantics; the server does not open the path. Scan timing/filter/HEAD and path handoff dispatch tests passed; the timing tests passed 7/7, and compileall/API-envelope lint passed. Strict JIN-07 remains active. + + +**P17 route inventory checkpoint, 2026-10-14:** A fresh registration-time census found 698 legacy `/api/` method/path pairs, 618 FastAPI OpenAPI-declared pairs, 614 exact overlaps, and 84 unmatched legacy pairs. This intentionally does not count custom ASGI handlers outside the FastAPI schema, normalize route aliases, or prove method-gate/behavior parity. Treat it as a gap-finding aid, not acceptance evidence. The primary Flask lifespan, WSGI fallback, framework extensions, and Flask/Jinja dependencies remain active. + + +**P17 native HEAD parity follow-up, 2026-10-14:** Added HEAD support and empty-body handling for scan-job status, desktop path info, browser-player settings, current-user role, and icon-pack reads where the legacy GET routes also supplied HEAD. The focused ASGI selection passed 4/4; compileall and API-envelope lint passed. The refreshed registration census is 698 legacy API method/path pairs, 621 FastAPI-declared pairs, 617 exact overlaps, and 81 unmatched legacy pairs; custom ASGI dispatch and behavior still require separate review. + + +**P17 remaining GET/HEAD parity, 2026-10-14:** Added Flask-compatible HEAD handling for playtime share SVG, quality-profile detail, unmatched bad-match reason catalogue, and the custom `/api/auth/runtime-bootstrap` ASGI handler. Focused ASGI/native quality selection passed 13/13 (one upstream Starlette/httpx deprecation warning); the two auth bootstrap contract/session tests passed 2/2; compileall and API-envelope lint passed. The OpenAPI census still does not include custom ASGI bootstrap dispatch. + + +**P17 admin Newsletter APIs, 2026-10-14:** The React Newsletter page's `GET`/`HEAD /api/admin/newsletters`, `GET`/`HEAD /api/admin/newsletters/{id}`, and `POST /api/admin/newsletters` now dispatch through FastAPI. The admin/token and CSRF-before-auth gates, no-store reads, strict request schema, SMTP/feature readiness, existing-member allowlist, recipient deduplication, durable pending status, and generic delivery-failure projection are preserved. Legacy newsletter contract tests plus outer-ASGI fake-adapter list/detail/send/HEAD/CSRF checks passed 22/22; no email was sent. Compileall, API-envelope lint, and scoped diff checks passed. + + +**P17 IGDB availability check, 2026-10-14:** `GET`/`HEAD /api/check_igdb_id` now dispatches through FastAPI with the existing signed-in member gate, integer query parsing, `available` response, and safe database errors. The outer-ASGI fake-adapter check passed 1/1, with invalid-id and HEAD cases included; no database or provider call ran. Compileall and API-envelope lint passed. + + +**P17 email-confirmation API, 2026-10-14:** `GET`/`HEAD /api/auth/confirm/{token}` now dispatches through FastAPI. It keeps the 15-minute signed-token expiry, confirmed/already-confirmed states, invalid/expired/not-found envelopes, and the account verification transition. The outer-ASGI fake-adapter case plus existing confirmation API tests passed 4/4; compileall and API-envelope lint passed. No account data was changed. + + +**P17 IGDB cover-thumbnail lookup, 2026-10-14:** `GET`/`HEAD /api/get_cover_thumbnail` now shares the native signed-in IGDB lookup router. It preserves numeric-id validation and the cover URL/not-found response while keeping provider failures generic; tests use a fake adapter and made no provider calls. The combined IGDB availability/cover outer-ASGI check passed 1/1; compileall, API-envelope lint, and scoped diff checks passed. + + +**P17 route census after Newsletter/auth/IGDB reads, 2026-10-14:** Registration-time snapshot: 698 legacy `/api/` method/path pairs, 635 FastAPI OpenAPI-declared pairs, 631 exact overlaps, 67 unmatched legacy pairs. Custom ASGI routes (including runtime bootstrap), path aliases, method-gate correctness, and behavior parity are excluded; use the count only to locate remaining route work. Flask lifespan, WSGI fallback, extensions, and framework dependencies still block strict JIN-07. + +**P17 admin Server Settings API cutover, 2026-10-14:** `GET`/`HEAD`/`PUT /api/admin/server-settings` now dispatches through FastAPI. It preserves the safe public settings projection, bounds/text limits, admin and token-scope gates, CSRF-before-auth, partial updates that retain unrelated settings/secrets, no-store reads, and HEAD behavior. The outer-ASGI fake-adapter test and existing Server Settings contract suite passed 38/38; compileall, API-envelope lint, and scoped `git diff --check` passed. No settings were persisted. Remaining route families and the primary Flask/WSGI/framework dependency retirement still block strict JIN-07 completion. + + +**P17 member password-reset confirmation, 2026-10-14:** `POST /api/auth/password-reset/{token}` now dispatches through FastAPI. The 15-minute token expiry, one-time token clearing, anonymous-only behavior, signed-session conflict, CSRF-before-auth, password length/match validation, safe invalid-link response, and login next URL are preserved. Outer-ASGI fake-adapter coverage passed for success, expired token, weak/mismatched password, missing CSRF, and signed-in conflict. No password or account changed. Compileall, API-envelope lint, and scoped `git diff --check` passed. Password-reset request/email delivery and login/registration remain separate auth routes; strict JIN-07 also requires all remaining route parity and Flask/WSGI/dependency retirement. + + +**P17 member password-reset request and confirmation, 2026-10-14:** `POST /api/auth/password-reset/request` and `POST /api/auth/password-reset/{token}` now dispatch through FastAPI. The requests preserve anonymous-only behavior, CSRF-before-auth, email validation, per-IP throttling, trusted-proxy hop selection, non-enumerating response bodies, no-store behavior, flash consumption, 15-minute token expiry, one-time token clearing, and password validation. Outer-ASGI fake-adapter tests cover missing CSRF, success, expired token, invalid email, throttling, signed-in conflicts, and trusted proxy selection; 4 focused ASGI auth/settings tests passed. The fake adapter prevented account writes and email delivery. Compileall, API-envelope lint, and scoped `git diff --check` passed. Login and registration APIs, other routes, and the Flask/WSGI/framework dependency retirement remain open. + + +**P17 password-reset API request and confirmation cutover, 2026-10-14:** Both `/api/auth/password-reset/request` and `/api/auth/password-reset/{token}` now dispatch through the native FastAPI router, with the static request path registered before the token route. CSRF-before-auth, anonymous-only behavior, trusted-proxy hop selection, per-IP throttling, non-enumerating response, no-store response, flash consumption, 15-minute token expiry, one-time token clearing, and password validation remain. Four focused outer-ASGI tests passed for invalid email, missing CSRF, signed-in conflict, throttle response, trusted proxy selection, invalid token, weak/mismatched password, and success. All account/rate/mail operations were fake-backed; no email was sent or account changed. Compileall, API-envelope lint, and scoped `git diff --check` passed. Login/registration, other route families, and primary Flask/WSGI/dependency retirement remain active. + + +**P17 member login and registration APIs, 2026-10-14:** `POST /api/auth/login` and `POST /api/auth/register` now dispatch through FastAPI. Login keeps the existing generic credential result, account-verification/disabled-account policy, per-IP/username throttling, safe `next` path, fingerprinted session ID, Flask-Login remember-cookie signature, and CSRF-before-auth behavior. Registration keeps username/email/password validation, invite and whitelist eligibility, case-insensitive duplicate checks, atomic single-use invite claim, verification-token email flow, 201 response, and the signed-in invite logout behavior. Four focused outer-ASGI tests passed across login, registration, and reset request/confirmation; adapters were faked, so no sign-in DB checks, registration writes, or email sends occurred. Compileall, API-envelope lint, and scoped `git diff --check` passed. Runtime Flask/WSGI and dependency retirement plus remaining route parity keep strict JIN-07 active. + + +**P17 member auth shell and logout routes, 2026-10-14:** Login, registration, password-reset request/confirmation, and `/logout` now dispatch through FastAPI. Logout preserves the `/login` redirect and clears the Flask-Login session keys and remember cookie with GET/HEAD parity. The earlier auth API checks cover CSRF, credentials, signed-in conflicts, safe redirects, remember-cookie compatibility, invite/whitelist policy, throttling, token expiry, and password rules. This logout addition passed the focused GET/HEAD cookie regression; auth adapters remained fake-backed for account/email writes. Compileall, API-envelope lint, and scoped `git diff --check` passed. The setup wizard transitions, remaining app routes, and primary Flask/WSGI/framework retirement still block JIN-07 completion. + + +**P17 member auth pages, 2026-10-14:** GET/HEAD `/login`, `/register`, `/confirm/{token}`, `/reset_password_request`, and `/reset_password/{token}` now serve the built React authentication shell through FastAPI. Existing signed-session redirects, invite/reset-token expiry checks, escaped one-shot flashes, no-store/Vary headers, remembered-session logout for invite registration, and HEAD behavior remain. Classic POST fallback handlers remain available for non-SPA clients. Six focused outer-ASGI tests passed across these pages and login/register/reset/logout APIs; fake adapters prevented account/email writes. Compileall, API-envelope lint, and scoped `git diff --check` passed. Setup transitions, remaining legacy routes, and Flask/WSGI/dependency retirement remain active work. + + +**P17 first-run setup shell pages, 2026-10-14:** GET/HEAD `/setup`, `/setup/smtp`, `/setup/features`, `/setup/igdb`, `/setup/integrations`, and `/setup/library` now serve the built React auth shell through FastAPI. They preserve initial step-1 initialization, current-step redirects, completed-setup notices, signed-session admin authorization, login `next` values, and flash behavior; POST compatibility forms remain on the existing route. Two focused outer-ASGI setup/auth-shell tests passed using a fake setup-state adapter and temporary shell. No setup settings or account data changed. Compileall, API-envelope lint, and scoped `git diff --check` passed. Setup step transitions, remaining legacy routes, and primary Flask/WSGI/framework retirement still block strict JIN-07 completion. + +**P17 first-run setup transition API, 2026-10-14:** `POST /api/setup/step` now dispatches through FastAPI. The route preserves CSRF-before-auth, step ordering, the first-admin creation session handoff, admin authorization for subsequent steps, schema validation, no-store/security headers, and existing response envelopes. Setup initialization helpers that depend on Flask `current_app` run inside an app context while database access remains native-session-scoped. Focused outer-ASGI checks for CSRF, out-of-order requests, admin-only later steps, and new session cookies passed 3/3 with fake transition data; no setup/account data or scan state changed. JIN-07 remains active for the legacy setup POST handlers, remaining route parity, and primary Flask/WSGI/framework dependency retirement. + +**P17 admin Operations read APIs, 2026-10-14:** `GET`/`HEAD /admin/api/ops/logs`, `/admin/api/ops/system`, `/admin/api/ops/summary`, `/admin/api/ops/devices`, and `/admin/api/library/health` now dispatch through FastAPI. Administrator role and API-token scope behavior, existing response payloads, bounded query limits, shared error envelopes, and HEAD responses remain. A regression found the plain-JSON log/theme projection helpers had been removed with template-only code even though `/ops/system` still called them; both helpers are restored, and the latest ORM event is serialized to a safe JSON projection. Fake-adapter outer-ASGI and projection tests pass; no database or device enumeration ran. Admin page shells and the rest of the Flask lifespan/WSGI/dependency gates remain; JIN-07 is active. + +**P17 native authentication loader decoupling, 2026-10-14:** Native route mounting no longer passes Flask-Login's `_user_callback` into request authentication. Signed-session and remember-cookie identities now use the shared fingerprint/active-user resolver with the injected user lookup; Flask-Login's callback remains available only as an explicit compatibility option. The first full ASGI regression run exposed the stale coupling (268 passed, one failed); after correction, the integrated ASGI native-route file passes 270/270. This removes one native request dependency, not Flask startup or the WSGI bridge. + +**P17 admin shell page pilot request-context retirement, 2026-10-14:** The opt-in FastAPI admin shell pilot now handles its static and dynamic page GETs directly with the shared signed-session resolver. Admin role/token scope checks, login redirects/flashes, session refresh cookies, shell response, security headers, and no-store behavior remain; the page handler does not enter Flask request hooks or contexts. `tests/test_native_admin_pilot.py` passes 82/82, including a guard that makes Flask request-context entry fail. The pilot remains opt-in, and default admin page requests, Flask startup, remaining route families, and dependency retirement remain open. + +**P17 admin shell pilot image packaging, 2026-10-14:** The Docker frontend stage now builds `admin-pilot` alongside the standard admin bundle and copies it into the runtime image. Startup fails with a direct error if the pilot flag is enabled without the required shell artifact. The pilot build completed (312 modules; 871 KB JS, 135 KB CSS) and the asset-packaging checks passed 5/5. No image was built or deployed. + +**P17 default FastAPI admin shell cutover, 2026-10-14:** When the packaged admin shell artifact exists, selected admin page GET/HEAD requests now dispatch through FastAPI automatically. `ONEIRODEX_NATIVE_ADMIN_PILOT=0` is the explicit WSGI rollback; `=1` still requires the artifact. Unsupported form writes and unmigrated APIs stay on their existing compatibility routes. Focused dispatch and native shell tests verify auto-selection, rollback, HEAD body/header parity, and the existing session authorization; further route parity and removal of Flask startup/WSGI/dependencies remain required for JIN-07. + +**P17 admin shell Compose controls and legacy redirects, 2026-10-14:** Standard, single-container, and review Compose files pass through `ONEIRODEX_NATIVE_ADMIN_PILOT`; empty/unset uses the artifact-based default, `0` rolls page requests back to Flask, and `1` requires the bundle. All three Compose configurations validate with `.env.docker.example` explicitly. Six retired admin bookmark paths now perform native FastAPI redirects with the existing login/admin gates and query preservation. Direct router and outer-ASGI checks passed 4/4; native admin shell/dispatch checks passed 86/86. Compileall, API-envelope lint, and `git diff --check` passed. Strict JIN-07 remains active for the remaining route tree, Flask lifespan/WSGI, and framework dependencies. + +**P17 admin route census, 2026-10-14:** An in-memory Flask blueprint registration census found 51 admin-prefixed, non-`/admin/api` GET rules. The packaged FastAPI shell covers 42 static pages, the native redirect router covers six retired aliases, dynamic shell matchers cover two editor/newsletter paths, and `/admin/statistics/data` now dispatches as a native bare-JSON read. This closes the registered admin page/API path inventory only; it does not certify all admin API parity or remove Flask from startup. + +**P17 classic admin statistics read, 2026-10-14:** `/admin/statistics/data` now dispatches through FastAPI with the original bare aggregate payload, administrator/session/token-scope checks, and GET/HEAD behavior. The separate `/api/admin/statistics` route keeps its existing envelope. Fake-reader parity tests pass, and the outer ASGI route gate is covered; no database query ran. Other API families and Flask lifespan/WSGI/dependency retirement remain active. + +**P17 IGDB lookup reads, 2026-10-14:** Authenticated GET/HEAD `/api/get_company_role` and `/api/search_igdb_by_id` now dispatch through FastAPI, retain their legacy payloads/login/errors and HEAD behavior, and validate numeric identifiers before an injected provider adapter. Fake-provider tests cover success, missing/malformed data, query-injection-shaped IDs, and anonymous access; no IGDB request ran. Remaining route families and the strict Flask/WSGI/dependency gate stay open. + +**P17 classic invite form cutover, 2026-10-14:** `/user/invites` GET/HEAD/POST and `POST /delete_invite/{token}` now dispatch through FastAPI using the existing signed browser session, form CSRF validation, invite data adapter, redirects, and JSON response contract. Native invite links carry a validated request origin into the shared adapter: configured Site URL wins, and request hosts must match the parsed origin and pass the existing explicit allowlist/private-host rules; malformed origins and ports fall back safely. The focused trusted-host and native account-summary suites passed 59/59; the invite form selection passed 14/14; the integrated `tests/test_asgi_native_browse.py` suite passed 277/277 after the origin handoff. Compileall, API-envelope lint, and scoped `git diff --check` passed. No invite was created or deleted outside fake-backed tests. Flask startup/WSGI, remaining legacy routes, and framework dependency retirement still block strict JIN-07 completion. + +**P17 public demo login cutover, 2026-10-14:** The opt-in `GET`/`HEAD /demo` route now runs in FastAPI with the same `ONEIRODEX_PUBLIC_DEMO` gate, active `demo-visitor` lookup, unavailable responses, `/discover` redirect, and fresh Flask-compatible signed session identifier. Success, disabled, and missing-user responses keep no-store/security headers; fake-backed tests cover those branches plus GET and HEAD, and no demo account was accessed. Three focused invite/demo dispatch tests pass, and the integrated `tests/test_asgi_native_browse.py` suite passes 279/279. Compileall, API-envelope lint, and scoped `git diff --check` pass. Three SQLite resource warnings remain in other tests in that suite. The direct Flask route remains as compatibility code until the primary runtime retirement gate. + + +**P17 auth/setup router regression, 2026-10-14:** after retiring the member pilot, the full DB-free native browse suite plus session-codec and import-boundary tests passed 298/298 in 121.08s. Three existing SQLite resource warnings about unclosed connections were emitted; no assertions failed. The focused auth/setup route selection passed 2/2, and `runtime_native` has no direct Flask/Jinja imports. DB-backed acceptance was not run because `TEST_DATABASE_URL` is unset. Flask lifespan, WSGI fallback, remaining legacy routes, and Flask/Jinja dependency retirement remain open. + +**P17 import-boundary ratchet, 2026-10-14:** `tests/test_runtime_import_boundary.py` now AST-scans every Python source file under `oneirodex/runtime_native/` and fails if a Flask-family or Jinja import is reintroduced. The focused module passes 3/3. This guards the native layer as route families move; it does not assert that the broader application or dependency tree is Flask/Jinja-free. + +**P17 unmatched mutation context reduction, 2026-10-14:** the FastAPI unmatched-mutation request/auth/CSRF handler now uses only the native request-local database scope, with no Flask app-context push. Nested pushes were also removed from update placement, kinded-game creation/audit, and retry matching. A regression makes app-context entry fail across route auth/CSRF tests. The adapter still receives Flask config, URL-map and login-manager compatibility objects; Flask startup, WSGI and dependency retirement remain. Focused mutation/library-tools/import-boundary checks passed 22/22; project-board tests passed 3/3; compileall, API-envelope lint, and scoped `git diff --check` passed. The native library freshness action now receives explicit app configuration in its service call and no longer pushes an app context. The native Steam metadata backfill also no longer adds an app context; the background library-wide refresh worker remains Flask-bound. + +**P17 no-template regression gate, 2026-10-14:** the test suite now fails if files return under `oneirodex/templates/` or Python adds explicit Jinja imports/render calls. The existing static-JS checks remain. A focused gate/native slice passed 27/27. Local packages remain Flask 3.1.3, Jinja2 3.1.6, and a2wsgi 1.10.10; the primary Flask/WSGI runtime therefore still prevents strict zero-Jinja completion. + +**P17 native unmatched dispatcher verification, 2026-10-14:** the route regression suite uses the real Flask-SQLAlchemy extension with in-memory SQLite and native request-local scope; an app-context stub fails if dispatch tries to enter Flask. Auth/CSRF and mutation routes passed within a 30/30 combined focused suite. This proves the unmatched route family only, not full route parity. + +**P17 admin user account actions, 2026-10-14:** `PUT /admin/api/user/0` and positive-ID user `PUT`/`DELETE` actions now dispatch through FastAPI. Create retains shared input validation, duplicate checks, password hashing, emailless placeholder policy, and audit attribution. Update/delete retain active-admin serialization, primary-admin and self-role protection, account/profile/password behavior, child-library and content filters, audit events, and session refresh when an admin changes their own password. DB-free direct route/auth/CSRF, create/read regression, outer-ASGI dispatch, and import-boundary coverage passed 45/45 (the newly added native DB test was excluded). The review database then passed native account create/update/delete, last-admin, concurrency, and emailless-account checks 21/21. The no-template, import-boundary, and inline-script guard set passed 33/33. Compileall, API-envelope lint, and scoped diff checks passed. Flask lifecycle/WSGI, remaining route parity, background worker ownership, and dependency retirement remain open; strict JIN-07 is not complete. + +**P17 emulator health and art-studio catalog reads, 2026-10-14:** `GET`/`HEAD /api/emulator/health` now dispatch through FastAPI and preserve the installed-core list, browser-playability checks, DOS-browser setting, deferred-core report, anonymous login redirect, and HEAD behavior. Admin art-studio stock, system-mark catalog, and system-mark lab GET/HEAD routes now use FastAPI with the existing bare/enveloped response shapes, admin/token-scope checks, input validation, and read-only asset projection. Direct route/auth/HEAD checks, outer-ASGI selection, and import-boundary checks passed 12/12; stock/art helper tests passed 15/15 with four database tests deselected. API-envelope lint, compileall, and scoped diff checks passed. No emulator or artwork files/settings were changed. + +**P17 refreshed API route gap inventory, 2026-10-14:** `python scripts/p17_route_parity.py` registers Flask blueprints and the actual native router set in memory, without app context, database access, or workers. It reports 529 unique legacy `/api/` and `/admin/api/` method/path pairs; 504 are covered by registered FastAPI patterns, five by explicit custom ASGI handlers, and 20 currently have no native match. Dynamic path-pattern containment is considered, but the census does not establish auth, CSRF, payload, or side-effect parity and does not count all non-API pages. The machine-readable gap ledger is [route-parity.json](../_private/review-p17-2026-10-14/route-parity.json). This is inventory evidence only; WSGI fallback and Flask/Jinja dependencies remain. + +**P17 public export, OpenAPI, and theme-font actions, 2026-10-14:** ES-DE and Pegasus export downloads and the public OpenAPI JSON/YAML files now have FastAPI routes. Exports retain signed-session login redirects, library ACL filtering, attachment names, media types, and HEAD behavior. Admin theme-font upload/delete now use explicit font-root/size configuration and preserve extension/signature validation, admin and token-scope checks, CSRF, status envelopes, and built-in-font protection; OS errors no longer return local paths. The focused export/OpenAPI/theme-font/emulator/art-studio route batch passed 17/17 (one upstream Starlette/httpx deprecation warning); API-envelope lint, compileall, and scoped diff checks passed. The refreshed API census now reports 504 FastAPI-pattern matches, five custom ASGI handlers, and 20 unmatched legacy methods. No font files or game exports were changed in persistent storage. Art generation/application/upload operations and system reset remain uncovered, as do the application-wide lifecycle, WSGI, worker, session/database, and dependency-retirement gates. + +**P17 API path inventory update, 2026-10-14:** The newest art-studio preview/generate/apply/download/batch, artwork generate/batch, cover search/apply/batch, image queue/delete/download/upload, and system-reset routes now have native FastAPI matches. Combined with prior exports, theme-fonts, OpenAPI, emulator, and catalog routes, `python scripts/p17_route_parity.py --json` reports 529 legacy API method/path pairs, 524 FastAPI matches, five custom ASGI handlers, and zero unmatched. The focused fake-backed slice passed 45/45; API-envelope lint, compileall, and scoped diff checks passed. No live provider requests/credits, persistent image downloads/uploads/art changes, or system reset ran. The complete legacy route map still records 162 non-API pairs without a direct FastAPI/custom-ASGI census match, and this census does not prove behavior parity. Flask creation/WSGI fallback, app-context-bound artwork and refresh workers, Flask-SQLAlchemy config/session ownership, and package retirement remain open; strict JIN-07 is active. + +**P17 FastAPI artwork context reduction, 2026-10-14:** Native cover apply, cover batch, art-studio apply/batch, and single/batch generated-art actions now use the request-local SQLAlchemy scope and explicit runtime config instead of Flask app contexts. Artwork helpers accept config and return the same `/static/...` paths; legacy callers retain their app-context fallback. Empty AI artwork URL/engine app settings now remain authoritative instead of falling through to process environment values. Focused AI/cover/art-studio/image tests passed 53/53 selected (13 database-marked cases deselected); follow-up mutation/download and adjacent native route slice passed 17/17. Tests used fakes for generation and provider actions; no image generation service or artwork provider was called. Compileall, API-envelope lint, runtime import-boundary and route-census checks (6/6), and scoped diff checks passed. `runtime_native` now has only two textual app-context sites: the image batch downloader wrapper and background library refresh worker; the native download path explicitly disables the former wrapper. FastAPI-owned app lifecycle, worker/session/config ownership, WSGI removal, non-API route parity, and Flask/Jinja dependency retirement remain open. + +**P17 image mutation context follow-up, 2026-10-14:** Removed the outer Flask app-context push from FastAPI image download selection; the request already owns the native database scope, and the batch helper's legacy wrapper is explicitly disabled on this route. The focused native image/cover/artwork group passed 34/34, the API census remained zero unmatched, runtime import-boundary tests remained 3/3, compileall/API-envelope lint/scoped diff checks passed. The only direct `app_context()` call left under `oneirodex/runtime_native/` is the background library-wide refresh worker in `library_admin_mutations.py`; scan orchestration still depends on Flask context and needs a worker/session ownership migration. Strict JIN-07 remains active. + +**P17 ORM/session boundary follow-up, 2026-10-14:** Shared SQLAlchemy model declarations now live in a framework-independent metadata module; importing the complete 80-table model registry no longer imports Flask or Jinja. Native route modules use a context-local native database facade, and ASGI request scopes now create direct SQLAlchemy `Session` objects from the configured engine. Library exports use the active native session rather than `Model.query`; member auth runtime bootstrap accepts the native database scope for its OIDC settings read. The broad selected native route/import-boundary group passed 61/61 (three database-marked tests deselected); follow-up outer-ASGI auth, session, and export coverage passed 55/55. The API path census remains 529/529 with zero unmatched. Runtime engine discovery still reads Flask-SQLAlchemy's internal registry, Flask remains the application factory and WSGI fallback, the background library refresh worker remains Flask-context-bound, and 162 non-API route pairs still require parity review. This is an import/session boundary reduction, not strict zero-Jinja completion; JIN-07 remains active. + +**P17 native auth bootstrap database verification, 2026-10-14:** strengthened the OIDC runtime-field check to execute a real SQLite query through the injected native engine scope while Flask app-context entry raises; the auth bootstrap, native session, and export focused rerun passed 40/40. This confirms the bootstrap's direct-session path on a real SQLAlchemy engine. It does not remove Flask engine initialization, WSGI, the background refresh worker, or unfinished non-API parity. + +**P17 native trailer route context inventory, 2026-10-14:** corrected a stale adapter description: FastAPI trailer filter/random-game reads already run in the caller-provided native session scope and do not push Flask app context. The existing trailer outer-ASGI suite passes 3/3. A fresh source scan now finds the only executable `app_context()` call under `runtime_native/` in the force-refresh background worker; scan orchestration also uses Flask request/application globals and remains the next larger worker migration. + +**P17 framework-neutral configuration and native engine ownership, 2026-10-14:** extracted `build_runtime_config()` so default production/test settings and relocated library paths can be resolved without importing Flask/Jinja; `legacy_app.create_app()` now consumes that mapping. The ASGI layer creates its native SQLAlchemy engine from explicit configuration instead of Flask-SQLAlchemy's private `_app_engines` registry, and disposes it on lifespan shutdown. Focused native session/import-boundary/lifespan tests passed 9/9, compileall and API-envelope lint passed, and the route census remains 529/529 API pairs matched (524 FastAPI patterns plus five custom ASGI handlers). The engine is currently duplicated during the compatibility period because Flask still initializes its own engine; Flask factory/WSGI fallback, scan worker ownership, non-API parity, and Flask/Jinja dependency retirement remain open. Strict JIN-07 remains active. + +**P17 force-refresh worker session ownership, 2026-10-14:** FastAPI passes its explicit native SQLAlchemy scope into the force-refresh coordinator and each per-game scan worker, so their database sessions use the ASGI engine rather than Flask-SQLAlchemy's engine. Scan orchestration now accepts an explicit app and scope, lazy-loads its legacy form dependencies, and imports without Flask/Jinja; the worker still enters Flask app context because scan processing and helpers rely on it. Fake-backed route/worker tests, native-session cleanup, import-boundary, and schedule helpers passed 31/31; compileall, API-envelope lint, and the API path census passed (529/529 pairs; path/method evidence only). No library scan ran. Scan-service app-context removal, WSGI/startup, non-API behavior parity, and Flask/Jinja package retirement remain open. + +**P17 scan identify and enrichment session ownership, 2026-10-14:** The force-refresh DB scope flows through scan game admission into identify, post-identify enrichment, image persistence/download, and per-game scan workers. `scanning.py`, `scan_identify.py`, `game_enrich.py`, `image_pipeline.py`, and their scan-only filesystem/filter/quality/catalog helpers resolve DB access through the native session facade when a scope is active; legacy callers still fall back to Flask-SQLAlchemy. Importing scan, identify, enrichment, and image modules together leaves Flask/Jinja unloaded. Malware policy now receives explicit runtime config, and local metadata read/write helpers accept explicit allowed directory bases that flow into identify and deferred size checks. Native post-identify enrichment, smart image processing, and per-game image download helpers avoid their own Flask app-context pushes when given the native scope; the enclosing scan worker still enters Flask context. Fake-backed scan/session/import-boundary and quality-profile/filter checks passed 32/32; scan-scope tests passed 4/4; focused identify/enrichment/image regressions passed 7/7; inline enrichment passed 1/1 after allowed-base propagation. Scanned-folder readers, malware config, and import-boundary checks passed 22/22 with 11 fixture skips. Licensed-catalog/set-completion/gamenames suites yielded 101 passing tests across the combined run and focused rerun. An earlier broad utility/game-core batch had 114 passes and three legacy image-call-argument assertion failures; conditional scope forwarding was corrected and all three focused regressions passed. Compileall, API-envelope lint, and route census passed; 529/529 is API path/method overlap only. No live scan, provider request, or image download ran. Flask startup/WSGI, enclosing scan context, other image contexts, non-API behavior parity, and Flask/Jinja retirement remain open. + +**P17 native refresh worker context reduction (2026-10-14):** the FastAPI force-refresh worker and native per-game scan orchestration now use the explicit native DB scope and app/config without pushing Flask app contexts; legacy scan callers keep their existing context behavior. The forced-refresh regression asserts context entry would fail, and the native scan-scope suite passed 4/4. Focused test run: 1 force-refresh worker check passed, plus the 4 scan-scope checks; compileall, API-envelope lint, and scoped diff check passed. `TEST_DATABASE_URL` is unset, so DB-backed scan lifecycle tests were not run, and no live library scan/provider/download was performed. This proves the context boundary choice under fakes, not end-to-end scan parity. Flask startup/WSGI, downstream image/context paths, non-API route parity, and Flask/Jinja package retirement remain open; JIN-07 stays active. + +**P17 framework-independent build metadata (2026-10-14):** moved the product version reader and process start timestamp into `oneirodex.runtime_info`. Reading `app_version` or `app_start_time` from the package no longer imports the Flask compatibility app, so native health and build identity helpers can use them without loading Flask/Jinja. Import-boundary, legacy export, and version-source checks passed 3/3; compileall, API-envelope lint, and scoped diff check passed. Flask startup/WSGI, non-API behavior parity, and Flask/Jinja package retirement remain open; JIN-07 stays active. + +**P17 readiness probe startup boundary (2026-10-14):** `/awake` now prepares runtime config and its native SQLAlchemy engine without constructing Flask, and the readiness helper defaults to the framework-free DB facade. GET/HEAD status, payload, headers, and content length remain covered. Liveness/readiness and framework-free helper checks passed 3/3; the subprocess import-boundary/version checks passed 1/1; compileall, API-envelope lint, and scoped diff checks passed. Normal ASGI lifespan still initializes Flask to start compatibility workers, and WSGI/non-API parity plus Flask/Jinja package retirement remain open. + +**P17 root entry redirect cutover (2026-10-14):** `/` and `/index` GET/POST now dispatch through FastAPI with the native DB scope; GET also preserves implicit HEAD behavior. Setup redirects remain first, anonymous sessions go to `/login`, and authenticated signed sessions go to `/discover`. The setup helper now reads through the native DB facade, preserving Flask-SQLAlchemy fallback for legacy callers. Direct native route tests passed 2/2, outer-ASGI routing passed 1/1, the setup redirect-loop source guard passed 1/1, and the runtime import-boundary check passed 1/1. No setup state was changed in a database. The API census remains 529/529 path-method matches; the all-route census reports 120 unmatched non-API pairs and is not behavior-parity evidence. Flask lifespan/WSGI, remaining routes, worker behavior, and Flask/Jinja package retirement remain open. + +**P17 native install manifest, 2026-10-14:** `/manifest.webmanifest` now resolves branding inside the native database scope, applies runtime security headers, and dispatches before Flask initialization. Focused ASGI coverage verifies GET/HEAD, response content type/cache/length, and that Flask construction is forbidden. This is a route-boundary migration only; it does not close the remaining 119 non-API route pairs or retire the compatibility runtime. + +**P17 `/restricted` redirect cutover, 2026-10-14:** Migrated the protected legacy redirect to FastAPI. Anonymous users keep the login flash/return target; authenticated users still land on `/welcome`. Focused redirect tests passed 3/3, including no-store and signed-session paths. This does not close the remaining route or runtime migration gates. + +**P17 member download and play-test shells, 2026-10-14:** Added `/downloads` to the native authenticated React shell route set and migrated the legacy `/playromtest` informational page while preserving its flash. Focused member-shell tests passed 3/3, and the route census now finds both paths in the FastAPI route map. The React downloads behavior and auth remain covered by their existing page/API paths; this change does not close other legacy route families. + +**P17 native play redirect, 2026-10-14:** Migrated `/play_game/` to FastAPI using the native session, existing library ACL helper, and browser-play URL resolver. Tests cover playable, unsupported, missing, inaccessible, flash/session, and outer-ASGI dispatch behavior (3 passed). The remaining route census still contains 115 unmatched method/path pairs, including compatibility and admin-shell paths; it does not establish full parity. + +**P17 native game-details shell, 2026-10-14:** Migrated `/game_details/` shell dispatch to FastAPI with UUID validation, game existence lookup, and the existing library/content ACL check. Focused member-shell tests passed 4/4, covering allowed, missing, invalid, and denied cases. The all-route census now recognizes the page route; remaining gaps include other legacy browser actions and the unbuilt local admin shell artifact. + +**P17 account/settings redirect slice, 2026-10-14:** FastAPI now handles GET/HEAD for the profile-view, password, and preferences-panel compatibility URLs with signed-session auth, login continuation, no-store, and security headers. POST methods stay on the legacy compatibility route. Focused redirect/play tests passed 7/7; the route census distinguishes remaining POST methods from the migrated GET/HEADs. + +**P17 native Library shell, 2026-10-14:** `/library` now serves through FastAPI while preserving default preference creation, saved-cookie and URL filters, sort/page settings, library-name error handling, catalog counts, and scan/unmatched empty-state metadata. Focused member-shell/site redirect tests passed 9/9; test adapters returned explicit counts and performed no database/provider work. Remaining library CRUD and file routes are separate migration work. + +**P17 Library first-visit regression, 2026-10-14:** Added a focused check proving `/library` creates and commits a missing UserPreference row through the native session, and retains it on the authenticated user before rendering. The expanded member-shell suite passed 6/6; no live database was touched. + +**P17 native design-system styleguide, 2026-10-14:** `/dev/styleguide` now uses FastAPI with the same admin-only gate, token scope check, theme/icon assets, CSRF token, flash output, and cache/security headers. The renderer accepts explicit runtime inputs and no longer imports Flask at module load; its legacy wrapper still serves the Flask route during transition. Focused native and styleguide coverage checks passed 4/4. + +**P17 native download-status poll, 2026-10-14:** Migrated `/check_download_status/` to FastAPI, enforcing the same per-user row ownership and preserving success, invalid-ID, and not-found envelopes plus security logging and no-store headers. Focused download/styleguide tests passed 6/6. + +**P17 Scan Filters redirect, 2026-10-14:** Native GET/HEAD `/delete_filter/` now preserves the admin login/role/token-scope gates and warning redirect to Scan Filters. The POST deletion remains on the legacy CSRF-protected mutation path. Focused admin redirect tests passed 4/4. + +**P17 Scan Filters mutation follow-up, 2026-10-14:** The legacy POST `/delete_filter/` now shares the native admin scan-filter data adapter and CSRF validator. Successful deletion commits, flashes the same message, and redirects to `/admin/edit_filters`; missing rows and failures retain 404/500 behavior. Focused scan-filter/admin redirect tests passed 7/7. + +**P17 member download deletion form, 2026-10-14:** Migrated `/delete_download/` POST to FastAPI. The route applies CSRF validation, limits parsed form fields/size, scopes deletion to the current user, commits via native session, and preserves the redirect/flash and missing-row 404. Focused download tests passed 6/6. + +**P17 admin download deletion form, 2026-10-14:** Migrated `/delete_download_request/` POST to FastAPI. It uses bounded form parsing, CSRF validation, admin and token-scope checks, preserves event logging, flashes and redirect, and retains the not-found flow. Focused download tests passed 7/7. + +**P17 artwork refresh progress, 2026-10-14:** `/check_image_refresh_progress/` now dispatches to FastAPI with the existing admin/session gate. Cached job states remain raw response bodies while missing jobs keep the success envelope. Focused admin ops/path matcher plus parity tests passed 12/12. The all-route source checkout census is 730 legacy pairs, 613 FastAPI pairs, 14 custom ASGI pairs, and 103 unmatched pairs. The local admin SPA build artifact is absent, so this source census cannot count packaged admin shell routes; the unmatched list otherwise consists of non-API legacy pages/forms. These counts prove only path/method mapping, not behavior parity or zero-Jinja readiness. + +**P17 library deletion progress, 2026-10-14:** The JSON poll and SSE stream now dispatch through FastAPI with admin/session/token-scope checks. Raw worker job states, missing-job envelope, SSE event framing/headers, and terminal cleanup are covered; focused admin ops/path/parity checks passed 14/14. Current source all-route census: 730 legacy pairs, 615 FastAPI pairs, 14 custom ASGI pairs, 101 unmatched pairs. The local admin SPA artifact is absent; path counts do not prove behavior parity. JIN-07 remains active. + +**P17 ASGI dispatch regression, 2026-10-14:** Extended the integrated lazy-ASGI admin Ops test to exercise image progress, library deletion poll, and deletion SSE routes, with Flask app-context use forbidden. The focused integrated test passed 1/1; the direct router/auth/streaming and path-census group passed 14/14. + +**P17 scan-job form mutations, 2026-10-14:** Migrated POST cancel/delete/clear-all/clear-by-status actions to FastAPI with bounded form parsing, CSRF, admin and token-scope gates, native session writes, flash redirects, and missing-job/status validation. Native action tests passed 3/3; integrated lazy-ASGI dispatch passed 1/1 with CSRF form data. The source census is now 730 legacy pairs, 619 FastAPI pairs, 14 custom ASGI pairs, and 97 unmatched pairs. `/restart_scan_job/` remains unmatched because restarting crosses into the scan worker lifecycle; the admin SPA artifact is also absent from the source checkout census. JIN-07 remains active. + +**P17 native scan restart and worker boundary, 2026-10-14:** `/restart_scan_job/` now uses the shared queue policy and starts its worker with explicit native database scope; completion/failure queue drains pass that scope forward. `scan_queue` no longer imports Flask at module load and imports the framework-free scan service instead of the legacy utility shim. The combined scan-route, worker-scope, integrated ASGI, import-boundary, and route-parity selection passed 12/12; compileall and API-envelope lint pass. No database-backed scan was run because `TEST_DATABASE_URL` is unset. Current route census: 730 legacy, 620 FastAPI, 14 custom ASGI, 96 unmatched. Strict JIN-07 remains active while startup/WSGI and transitive Jinja retirement remain. + +**P17 destructive library actions and unmatched-folder forms, 2026-10-14:** Library deletion, single-game library removal, unmatched-folder disk removal, and full-game disk deletion now have FastAPI dispatch with native database scopes, CSRF/admin checks, scan guards, and path safety. Legacy unmatched folder clear/toggle/status/bulk forms also dispatch natively with hidden-form CSRF, flash redirects, and AJAX compatibility. The combined fake-backed regression selection passed 29/29; no live library/database/disk mutation ran. Current source census: 730 legacy pairs, 628 FastAPI pairs, 14 custom ASGI pairs, 88 unmatched. Strict zero-Jinja remains incomplete because Flask startup/WSGI, native parity and dependency retirement gates are still open. + + +**P17 library and unmatched cleanup actions, 2026-10-14:** FastAPI now dispatches native library/game removal, full-game disk deletion, unmatched-folder disk deletion, game-image deletion, and the legacy unmatched-folder clear/toggle/status/bulk forms. Image paths are confined to the configured artwork directory; game and unmatched disk actions retain strict allowed-root checks. Route, CSRF, worker, and path-escape regressions passed with faked destructive effects; no live data or files were deleted. Current all-route census: 730 legacy, 629 FastAPI, 14 custom ASGI, 87 unmatched. Flask startup/WSGI and package retirement remain open. + + +**P17 route-census correction, 2026-10-14:** The all-route scratch census now uses `oneirodex/static`, matching the application’s mounted admin pilot artifact when present. Earlier P17 checkpoints are historical; the refreshed current report is 730 legacy method/path pairs, 679 FastAPI matches, 14 custom ASGI matches, and 37 unmatched. See [the private all-route inventory](../_private/review-p17-2026-10-14/all-route-parity.json). These counts are path inventory only and do not prove behavior parity or strict zero-Jinja readiness. + + +**P17 legacy game-download initiation, 2026-10-14:** `GET /download_game/` now dispatches natively while preserving signed-session login, library access, allowed-root checks, duplicate basket behavior, request creation, flashes, and redirects. Focused download and all-route census tests passed 8/8; compileall and API-envelope lint passed. Effects were adapter-backed and no live library/database mutation ran. Current path census: 730 legacy pairs, 680 FastAPI, 14 custom ASGI, 36 unmatched. Strict zero-Jinja is still incomplete. + + +**P17 update/extra download initiation, 2026-10-14:** `GET /download_other///` now dispatches through FastAPI with file type/ID validation, game-scoped lookup, member ACL, allowed-root and file-existence checks, duplicate-request handling, streaming request creation, flashes, and legacy redirects. Focused download/census checks passed 10/10; compileall and API-envelope lint passed. Current all-route path inventory: 730 legacy pairs, 681 FastAPI, 14 custom ASGI, 35 unmatched. Effects were adapter-backed; strict zero-Jinja remains incomplete. + + +**P17 legacy profile editor, 2026-10-14:** `/settings_profile_edit` GET/HEAD and POST now use FastAPI, including legacy hidden-form CSRF, bounded multipart parsing, shared avatar validation/storage, and compatible redirects/flashes. The focused avatar, download, and route-census group passed 18/18; compileall and API-envelope lint passed. Current all-route inventory: 730 legacy method/path pairs, 683 FastAPI, 14 custom ASGI, and 33 unmatched. Strict Jinja/runtime retirement remains active. + + +**P17 legacy password form, 2026-10-14:** `POST /settings_password` now uses native session auth, hidden-form CSRF, the established password checks, session fingerprint refresh, remember-cookie behavior, and avatar/password-page compatible redirects and flashes. Focused password/avatar/census checks passed 18/18; compileall and API-envelope lint passed. Current all-route path inventory: 730 legacy, 684 FastAPI, 14 custom ASGI, and 32 unmatched. Strict zero-Jinja remains incomplete. + + +**P17 legacy preferences form, 2026-10-14:** `POST /settings_panel` now uses native FastAPI auth, bounded form parsing, hidden-token CSRF, native persistence, and the existing JSON response envelope. Partial saves retain unsubmitted values; full-form missing checkboxes clear correctly. Focused preferences/password/avatar/census checks passed 26/26; compileall and API-envelope lint passed. Current all-route inventory is 730 legacy, 685 FastAPI, 14 custom ASGI, 31 unmatched; strict runtime retirement remains open. + + +**P17 admin feature and IGDB legacy writes, 2026-10-14:** FastAPI now handles legacy feature-toggle form submission and IGDB save/test POST paths with admin, CSRF, and token-scope checks. Feature defaults, response fields, and secret-safe IGDB projection are preserved. Focused feature/IGDB/preferences/census tests passed 21/21; compileall and API-envelope lint passed. No provider call or persistent setting mutation ran. Current all-route inventory: 730 legacy, 688 FastAPI, 14 custom ASGI, and 28 unmatched; strict runtime retirement remains open. + + +**P17 legacy whitelist form, 2026-10-14:** FastAPI now handles `POST /admin/whitelist` with hidden-form CSRF, administrator authorization, native email validation/persistence, and existing redirect/flash behavior. Consolidated account/admin/download/import-boundary/census checks passed 50/50; compileall and API-envelope lint passed. Current all-route inventory is 730 legacy method/path pairs, 689 FastAPI, 14 custom ASGI, and 27 unmatched. Strict Flask/Jinja retirement remains incomplete. + + +**P17 legacy SMTP actions, 2026-10-14:** `/admin/smtp_settings` and `/admin/smtp_test` POST now use FastAPI with admin/CSRF checks, validated native persistence, secret-safe output, and the existing connection-test result semantics. Consolidated SMTP/admin/account/download/import-boundary/census checks passed 57/57; compileall and API-envelope lint passed. No SMTP connection was made. Current all-route inventory: 730 legacy, 691 FastAPI, 14 custom ASGI, and 25 unmatched. Strict runtime retirement remains open. + + +**P17 legacy invite quota form, 2026-10-14:** `/admin/manage_invites` POST now runs through FastAPI with bounded form parsing, hidden-token CSRF, admin/token authorization, existing quota validation, native persistence, and redirects/flashes. Focused form and outer-ASGI dispatch checks passed 6/6; compileall/API-envelope lint pass. Current route inventory: 730 legacy, 692 FastAPI, 14 custom ASGI, and 24 unmatched; runtime retirement remains active. + +**P17 final runtime and member-flow gate, 2026-10-14:** Native ASGI startup builds `NativeRuntimeContext` and the native SQLAlchemy engine without constructing Flask; lifecycle and authenticated member/admin dispatch checks run with Flask and Jinja imports blocked. `requirements.txt` explicitly pins `itsdangerous==2.2.0` for native signed-session compatibility and contains neither Flask nor Jinja packages; the legacy test harness keeps Flask/Jinja in `requirements-dev.txt`. A clean `docker build --tag oneirodex:p17-audit .` passed and built member, auth, admin, admin-pilot, and ops bundles. Inside that image, Flask and Jinja are absent, native startup registers 159 FastAPI routes, and the runtime context is `NativeRuntimeContext`. The source template census has zero HTML templates, zero render calls, and zero explicit Jinja imports; route inventory is 730 legacy method/path pairs, 716 FastAPI matches, 14 explicit ASGI handlers, and zero unmatched. Verification: all native route modules passed 715/715; native browse/dispatcher 310/310, dispatcher follow-up 34/34, runtime/import boundary 9/9, RBAC/Arr 23/23, combined native ASGI 78/78, final route/runtime/package/admin-shell gate 361/361, member SPA 1164/1164, member production build, CSS-token lint, API-envelope lint, compileall, and `git diff --check` pass. The member suite also verifies Game Details menu keyboard semantics and Store Connections focus recovery. `TEST_DATABASE_URL` was unset, so DB-backed legacy integration checks did not run. The local review service is healthy but lacks the dedicated screenshot account; no account or staging data was changed, and no fresh browser capture is claimed. The full 6,656-test non-integration suite was started and interrupted at 1% due to its Windows runtime; it surfaced one stale admin-pilot assertion, which was corrected and then passed in the focused 361-test rerun. + +**P17 native admin shell dispatch and launch-media refresh (2026-10-14):** GET/HEAD `/admin/features` now reaches the packaged admin shell instead of being intercepted by the POST-only pilot API route. The dispatch regression and native admin pilot/features suites passed 121/121; the capture login redirect regression passed 2/2. Against an isolated, populated current-build capture instance, all 52 stills were refreshed, including member/admin views and README slots. README hero, section headers, feature cards, and 20 poster tiles rendered after correcting the asset generator to load both generated shared glyphs and rail glyphs. The updated how-to video set is being recaptured; final completion and visual QA are recorded in the prelaunch execution report. + +**Capture completion correction (2026-10-15):** The isolated capture completed all 20/20 narrated how-to videos, generated their VTT transcripts and media index, and refreshed all 52/52 stills. README art regenerated with 20 posters. Discover and Admin Ops posters were spot-checked after generation. The status above records the intermediate state when it was written. + +**P18 security follow-up (2026-10-15):** Standard scan `61195604-0742-461c-803b-4e73095bd397` reported one medium, deployment-conditional forwarded-IP trust finding. Native authentication/reset and proxy compatibility now require `TRUSTED_PROXY_CIDRS` to match the immediate peer before using forwarded client/origin headers; supported launchers disable Uvicorn's implicit proxy rewriting. The Unraid CA template and env/runbook guidance expose the CIDR setting. The combined focused auth/proxy/OIDC/rate-limit and corrected regression selection passed 18/18, including reset-origin peer validation. Changed Python modules compile and the Unraid XML template parses. The scan is explicitly partial and targets the pre-fix snapshot; it does not clear the full repository. See the private reconciliation report for open launch gates. diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 4485ad4a5..de6f53e75 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -2,13 +2,287 @@ > **Doc status:** Active -Oneirodex is one Flask app (`oneirodex/`) plus three React SPAs and a typed HTTP client. This page is the map, not a rewrite of [ADR 0005](../adr/0005-api-client-is-the-requester.md) or [agent-locks.md](agent-locks.md). +Oneirodex dispatches HTTP requests through the FastAPI ASGI runtime, with three React SPAs and a typed HTTP client. Flask route modules remain as development-time compatibility and parity references; the runtime image no longer installs Flask or Jinja. This page is the map, not a rewrite of [ADR 0005](../adr/0005-api-client-is-the-requester.md) or [agent-locks.md](agent-locks.md). + +The migration notes below are dated implementation checkpoints; their interim +runtime descriptions may be superseded by the current status above and in the +[Jinja/runtime inventory](admin-jinja-inventory.md). + +**Current runtime acceptance (2026-10-14):** P17 removed Flask construction, +WSGI dispatch, and Flask/Jinja packages from the runtime image. Normal startup +creates `NativeRuntimeContext`; the current route census maps 730 legacy +method/path pairs to 716 FastAPI patterns and 14 ASGI handlers with no +unmatched paths. This is route inventory coverage, not proof of every +side-effect or browser state. P18's bounded UI, accessibility, and capture +evidence and exclusions are recorded in the +[private acceptance report](../_private/evidence/p18-ui-acceptance-2026-10-14.md). + +The package root exposes legacy Flask objects lazily from +`oneirodex/legacy_app.py`; those exports are for the development compatibility +suite and require `requirements-dev.txt`. Normal startup creates +`NativeRuntimeContext`, registers framework-independent ORM models, and serves +through FastAPI. The native handlers preserve the signed-session, CSRF, +authorization, response, and side-effect contracts documented in the dated +route migration notes below. Production `requirements.txt` and the Docker +image exclude Flask, its extensions, and Jinja2; the development test harness +still installs Flask/Jinja so historical compatibility tests can run. + +Native code that reuses a compatibility helper must handle Flask being absent, +not only an absent Flask application context. The 2026-10-15 local image check +caught this in the login password-timing helper, member lifecycle path, and +theme-font fallback. Those helpers now use framework-independent logic or +catch the missing optional import and use the configured runtime fallback; +`tests/test_native_runtime_framework_free.py` guards the boundary. + +The ORM declarations share `ModelBase.metadata` across the native runtime and +development compatibility adapter. Alembic imports that framework-independent +metadata directly, so startup migration checks run in the production image +without importing Flask. `/pulse` is liveness; `/awake` is readiness and checks +database connectivity plus initialization. Route-count coverage records +registered method/path pairs; it is not a substitute for per-route behavior, +side-effect, or real-platform acceptance. + +The dated implementation checkpoints below describe how the migration +progressed. Treat their interim statements about Flask startup, WSGI fallback, +route gaps, and dependency removal as historical whenever they conflict with +the current runtime status above. + +Storage hardlink preview/apply now also dispatch through FastAPI with the +existing admin, CSRF, feature-flag, library-root path-safety, and audit behavior. +Tests exercise apply through a fake adapter, so verification does not create a +filesystem hardlink. Flask startup and framework dependency retirement remain +open. + +The admin malware-scan action also dispatches natively with its configured-root +allowlist, admin and CSRF gates, and existing result status contract. Its tests +use a fake scanner; production scanning remains behind the explicit admin action. + +The member free-game claim-assist mutation and admin cropped-library-image +preview now use the same native request boundary. The claim keeps ownership +work behind the scoped data adapter; the image preview transforms the uploaded +base64 image in memory and does not write an asset. + +LiveKit token issuance also has a native FastAPI handler. It keeps the room +membership check, feature/configuration gates, child video restrictions, and +the existing signed-user identity; route tests use a fake mint adapter. + +Native write handlers apply CSRF to browser-session requests and retain the +existing API-token authentication path. + +The first-run setup shell pages, ordered `POST /api/setup/step` transition, +and classic HTML form POSTs (`/setup/submit` plus the five step URLs) now +dispatch through FastAPI with existing signed-session and CSRF compatibility. +Form field names are translated into the same validated transition contract; +the first-admin session handoff, step and admin gates, redirects, and flash +cookies are preserved. Setup seed helpers receive explicit configuration and +task-owner arguments without pushing a Flask application context. Other legacy +routes and the primary-runtime/dependency-retirement gates remain. + +The Ops log, system, summary, device, and library-health reads also dispatch +through FastAPI with administrator and token-scope checks. Flask still registers +the compatibility URL map during startup, while FastAPI owns request dispatch. +Six retired admin bookmarks now use native FastAPI redirects with the original +admin/login checks and query forwarding: `/admin/help`, `/admin/library_tools`, +`/admin/scan_management`, `/admin/server_logs`, `/admin/system_logs`, and +`/admin/server_status_page`. Their targets remain the shared Help, Libraries & +scans, and Ops pages. +The classic `/admin/statistics/data` aggregate is also native and retains its +bare JSON payload alongside the newer `/api/admin/statistics` envelope. + +Native route authentication now resolves signed session and remember-cookie +users through the shared fingerprint and active-user checks plus an injected +user lookup. Native dispatch no longer calls Flask-Login's user-loader hook. +Admin page GET/HEAD requests use the packaged FastAPI shell. The retired +`ONEIRODEX_NATIVE_ADMIN_PILOT` setting has no effect and is no longer passed +through Compose. Form writes use native compatibility handlers. The Docker +entrypoint requires the packaged shell bundle. + +## Approved runtime migration + +The approved target is FastAPI with compatibility for existing signed sessions, +static React page shells, and no Flask/Jinja2 dependency at final acceptance. +Normal startup now uses framework-independent config, native database setup, +and a `NativeRuntimeContext`; it no longer constructs Flask. FastAPI owns +registered route dispatch. The packaged admin shell is served by FastAPI and is +required by the container entrypoint. Member authentication and setup shells are served by the mounted +FastAPI auth/setup routers; the obsolete member-shell pilot flag and its second +Flask request-dispatch implementation have been removed. The member runtime +bootstrap resolves the existing signed session and issues compatible CSRF/session +cookies directly in FastAPI. Admin page requests use the shared signed +session resolver directly, while selected compatibility handlers still use the +shared `oneirodex/runtime_native/flask_compat.py` adapter for Flask request hooks, +session handling, redirects and response finalization. Admin auth also preserves remember +cookie and scoped Bearer-token precedence. FastAPI-native route and admin +bootstrap authentication now use the shared password-fingerprint resolver +directly, while Flask-Login remains available to the compatibility routes. +The single-game favorite and play-status APIs, admin library-platform +options, the member role/icon-pack/username endpoints, the release-calendar and quality-profile APIs, announcement reads and creation, +notification inbox/preferences actions, the admin notification-bus test action, +and personal API-token management use +request-local ORM sessions where needed and direct Starlette JSON responses; they no longer push Flask app +contexts. Their legacy response shapes, ACL, CSRF, and signed-session behavior +remain covered by route tests. +The admin challenge-solver and ambient-lighting status, config, and test routes +now handle authentication, authorization, response serialization, and +request-scoped ORM work in FastAPI. Config saves accept explicit configuration +and preserve CSRF-before-auth and outbound URL validation. Tests inject fake +adapters and prevent any device/network probe during focused verification. +The member filter bundle, library-platform list, and taxonomy reads also resolve +auth and ORM work through native request scopes. The library fallback artwork +URL is now provided directly to the shared payload builder, avoiding a synthetic +Flask request context; the response error contract is built with the shared +framework-independent API helper. +The authenticated `/api/get_company_role`, `/api/search_igdb_by_id`, and +`/api/search_igdb_by_name` lookups now run in FastAPI with numeric identifier +validation where applicable and an injectable provider adapter. Name search +retains the optional platform filter and legacy result envelope; request +contracts are covered without making an external IGDB call. +The member locale GET/POST now uses the request-local session scope, pure +Starlette/API-contract responses, and the existing signed-session/CSRF +compatibility helpers without pushing Flask app context. +The authenticated `/browse_games` endpoint also uses the native DB scope. Its +pagination now runs through the shared SQLAlchemy session rather than +Flask-SQLAlchemy's request-context-aware paginator, while retaining the page +size cap, count, deterministic ordering, Flask JSON-provider serialization, +and typed duplicate-query handling. +The admin library-health summary and per-game health detail reads also use +native session scopes and Starlette JSON/error responses while retaining the +existing query parsing, role checks, scoring, and library ACL order. +The OIDC readiness endpoint now follows the same native scope and response +path; its local-only report still performs no IdP call, and role/Bearer-scope, +redirect, and HEAD behavior remain covered. +The FastAPI setup transition now passes explicit configuration and task-owner +arguments into library/font/core seed helpers, so its setup finalizer no longer +pushes a Flask application context. Legacy setup callers keep their existing +context-backed defaults; this is a bounded decoupling step, not framework +retirement. +Native Operations reads also no longer push their own Flask app context. +Configuration, database-engine access, and theme-root lookup are injected into +the existing snapshot helpers; the native request-local session remains the +ORM boundary, while legacy callers retain their defaults. +The admin `GET`/`HEAD /api/hltb/stats` endpoint also runs through FastAPI with +request-local SQLAlchemy scope. It retains the four-field coverage payload, +signed-session and admin-token checks, login redirects, no-store policy, and +HEAD behavior. Single-game and bulk HLTB refresh actions also use FastAPI with +CSRF-before-auth and existing provider adapters. +The Arr connector configuration `GET`/`HEAD`/`PUT` uses this native path. Its +shared public projection reports only whether credentials are configured; +secret values remain private. The Arr module `GET`/`HEAD`/`PUT` status/toggle +route also runs natively and stays available while the optional module is off. +The Arr indexer catalogue `GET`/`HEAD` also uses FastAPI; it reads saved +indexers and the local preset catalogue while preserving module/admin gates and +the bare payload. Indexer create/update/delete also run natively with +CSRF-before-auth, validation, and credential-redacted responses. Bulk import +and preset enablement, provider search/download, and hardlink preview/apply also +run natively. The `/api/arr/module` read/write also runs natively and stays +available while the optional module is disabled. +Single-indexer `GET`/`HEAD /api/arr/indexers/` now uses the same native +router and keeps its public, credential-redacted detail projection plus the +existing not-found envelope. +SMTP settings `GET`/`HEAD` also uses FastAPI and a shared framework-independent +public projection; the password remains represented only by a saved/not-saved +flag. FastAPI `PUT` preserves CSRF-before-auth, field validation, blank-password +retention, cache invalidation, and session refresh. Its `POST /api/admin/smtp/test` +action now also checks CSRF/auth natively and calls the existing tester; tests +use a fake tester and send no mail. +IGDB settings `GET`/`HEAD` follows the same approach: its shared projection +returns the client ID, test timestamp, and a secret-presence flag without the +secret value. The FastAPI `PUT` path also preserves CSRF-before-auth, +validation, blank-secret retention, session refresh, and cache invalidation; +the FastAPI `POST /api/admin/igdb/test` route preserves CSRF, safe result/error +payloads, and the last-tested timestamp while using the existing provider +adapter. +The custom chat emoji catalogue `GET`/`HEAD` is native as well; it preserves +the admin gate, limits metadata, no-store response, and generic storage-error +contract. Its DELETE action also uses FastAPI and validates CSRF before auth; +upload remains on its established handler. +Batch favorite, play-status, and wishlist mutations now use the native ORM +scope and shared framework-independent API envelope for empty-batch successes +and errors; cookie CSRF, Bearer handling, and partial-result semantics remain +intact. +Freshness inbox and store-search routes also use request-local ORM scope and +framework-independent errors. Store probes remain behind their injectable +adapter, and query duplicate handling still uses the first value. +Trailer filter and random-selection APIs now use the native session scope; +authentication redirects, result envelopes, HEAD and safe empty/error payloads +remain unchanged. The random selection itself performs no outbound provider +request. +Social status/friends/profile/activity reads and game related-media reads and +actions now use native request-local sessions. Profile avatar URLs are built +from the app's static path without a Flask test-request context; social-scope, +ACL, validation, and cookie-CSRF contracts remain. +Gaming/free-game feed reads and wanted-update list/actions now also use native +request-local sessions. Wanted-update file storage receives the configured +upload root explicitly, and its API responses use the shared framework-neutral +contract; feed/provider calls remain behind the data adapter. +Wishlist request list/create/cancel/resolve and the member password-change +mutation also use request-local sessions and framework-neutral API responses. +Password reset still refreshes the signed user session and remember cookie, and +wishlist audit events retain the initiating user ID. +The bounded freshness scan mutation now also uses native request/session +handling. Its local version-file check receives the Flask app explicitly so +the configured game-root allowlist remains enforced without relying on +`current_app`. +Companion capabilities, presence heartbeat, command enqueue/acknowledgement, +and client lifecycle reads/writes now use the request-local SQLAlchemy scope +and framework-independent response contract. Bearer scopes, CSRF, game ACL, +command ownership, validation, session refresh, and HEAD behavior remain. +Acquire readiness also uses native DB scope and receives app configuration +explicitly; its status calculation reads local configuration and database rows +without probing indexers, debrid services, or other providers. +The admin ROM-translation capability read also dispatches through FastAPI, +keeping the bare payload, admin-role/Bearer-scope checks, login redirects, +danger flash, security headers, and HEAD behavior. +Public member loading-icon bootstrap and admin settings GET/PUT also use native +request-local ORM scope. The member projection stays safe and bare JSON; the +admin GET stays bare JSON; PUT retains browser CSRF, admin/Bearer gates, input +errors, and the shared success envelope. +FastAPI's unmatched-route, unhandled-error, and CSRF rejection responses now +build the shared JSON envelope through a framework-independent contract and +no longer enter a Flask app context. Other native route persistence and startup still depend on +Flask. +The admin runtime-bootstrap endpoint now resolves its ORM work in a +request-local SQLAlchemy session without pushing a Flask app context; member +bootstrap's OIDC-label lookup uses the same boundary. Flask routes retain their +app-context session scope. The dedicated session scope is transitional while +remaining native routers are moved off Flask. +These are bounded transition +boundaries, not a replacement primary runtime, and Flask/Jinja remain required. +FastAPI auth/setup routers cover `/login`, `/register`, reset, confirmation and the six first-run setup URLs; the duplicate optional member pilot has been retired. Setup transitions use `/api/setup/state` and `/api/setup/step`. Member and admin SPA shells now +share a React preferences dialog backed by `/api/account/preferences`; the +`/settings_panel` endpoint redirects to that dialog. +Retaining Flask session hooks +during either pilot is transitional and does not establish zero-Jinja completion. +Legacy POST handlers and form endpoints still require compatibility review, but +page rendering no longer depends on Jinja templates. + +The daily digest, social notification and invite email bodies and the `/offline` +document use explicit non-template renderers. Theme and built-distribution URLs +are resolved by `oneirodex/utils/asset_urls.py`. The application no longer has +an authored template tree or Jinja render/filter/context-processor calls. This +does not remove the Flask/Jinja runtime dependency. + +The member SPA page shell now uses a Python HTML builder in +`oneirodex/utils/member_spa.py`; it preserves the React root data contract, +theme/icon assets, CSRF markup, flashes, delete modal and era atmosphere without +calling Jinja. The admin-gated design-system styleguide also builds its document +without Jinja now; React-owned admin routes also use the shared Python document +builder. Their empty wrapper templates were removed along with the unused +`base_empty.html` shell and styleguide template. The app's primary route/session +runtime is still Flask. + +The React-owned downloads page at `/admin/manage-downloads` uses that admin +shell and reads through the existing admin API. Its API exposes a filename +instead of the raw ZIP path, while the classic POST delete endpoint remains for +compatibility. The newsletter, invite roster, reference sets, statistics, features, themes, Server Settings, Arr, Attract Mode, discovery sections, whitelist, scan-filter, IGDB settings, chat emoji, SMTP, AI assist, admin Libraries & scans, manual identify, library add/edit, game image editor, and Libraries & scans routes also use the +shared shell; their established form POST compatibility paths remain. These +pages no longer load their former Jinja page bodies or query page state for document rendering. The React Integrations hub keeps metadata-provider and community-link editing on guarded APIs; SMTP and IGDB settings live on their dedicated React routes. The retired Jinja integration form partials and tab controller were removed. ## Runtime | Piece | Role | | ----------------------- | ------------------------------------------------------------------------------ | -| Flask (`oneirodex/`) | Session cookie, Jinja shells, `/api/**` + `/admin/api/**`, scans, play, social | +| Flask (`oneirodex/`) | Session cookie, shell/API routes, `/api/**` + `/admin/api/**`, scans, play, social | | member-app | Household SPA (`frontend/member-app`) | | admin-app | Operator SPA (`frontend/admin-app`) | | ops-glance | Ops pulse widgets (`frontend/ops-glance`) | @@ -16,17 +290,230 @@ Oneirodex is one Flask app (`oneirodex/`) plus three React SPAs and a typed HTTP | `@oneirodex/ui` | Shared chrome, envelope helpers, CSRF lookup | | Desktop companion | Tauri client on the Bearer transport | +The ASGI entrypoint handles `GET /api/auth/runtime-bootstrap` directly with a +framework-independent codec that preserves Flask's signed session cookie and +Flask-WTF CSRF token format. OIDC label lookup uses request-local ORM scope. +Auth-page, setup-page, and their supported form handlers dispatch through the +mounted FastAPI routers; the optional member-shell pilot and runtime marker have +been removed. Other account mutations use native FastAPI adapters. Flask startup +remains part of the compatibility runtime. + +The ASGI entrypoint serves `GET` and `HEAD /pulse` directly using a pure +liveness payload. `/awake` prepares framework-independent runtime config and a +native SQLAlchemy engine without constructing Flask, then runs the existing DB +and initialization readiness checks in request-local ORM scope and sends its +response directly from ASGI. Normal ASGI lifespan still initializes Flask to +start compatibility workers. It also +serves `/offline` directly using the member-free HTML builder, and +`/sw.js` plus `/vr/sw.js` from shipped application files with their existing +scope and no-cache headers; mutable library overrides cannot replace these +origin-wide worker scripts. `/favicon.ico` also uses the native response path +with its existing one-hour cache policy. A native FastAPI redirect handler +also serves `/` and `/index`. It keeps setup +redirects ahead of login checks, sends anonymous users to login, and sends +authenticated users to Discover; the setup utility uses the native DB session +facade when called inside this route scope. This cutover does not remove Flask +startup or the Flask extension/database ownership layer. + +`/api/theme/fonts.css` now uses the native FastAPI route for anonymous and +credential-bearing requests. It preserves first-value query override, +signed-session font preference, the shared stylesheet builder, cache/security +headers, and HEAD behavior. The shared FastAPI app still initializes after +Flask during the transition. Requests to `/api/emulator/installed-cores.js` +build the existing disk-backed WebRetro +allowlist directly in ASGI with its JavaScript content type and five-minute +private cache policy. +The shared native FastAPI API app converts unmatched HTTP errors and unexpected +handler failures into the standard API error envelope, preserves safe security +headers and does not expose exception details to clients. +`/manifest.webmanifest` reads the configured site label through a request-local +ORM scope and returns the shared install manifest payload directly from ASGI +with its one-hour cache policy. `/api/loading-icon` uses the native API router +and request-local ORM scope for the member-safe settings payload. Anonymous +`/api/setup/state` also uses request-local ORM scope and retains the shared +envelope, no-store policy, security headers, and HEAD behavior. +Credential-free `GET` and `HEAD /login` and `/reset_password_request` serve the +validated React auth shell; credential-bearing requests retain Flask-Login +dispatch, and form posts remain on Flask. +`/api/setup/state` now dispatches through FastAPI for anonymous and +credential-bearing requests. In-progress wizard settings require the same +signed administrator session as the Flask route; Bearer tokens do not expose +setup-only configuration. The endpoint keeps no-store and HEAD behavior. +`/api/attract-mode/settings` and `/api/attract-mode/user-override` now dispatch +through FastAPI for anonymous and signed-session requests. Per-user filters +and autoplay settings still overlay admin defaults, and saving an override +keeps browser CSRF, auth, audit, and session-refresh behavior. The authenticated read-only browse endpoint +`/browse_games`, trailer endpoints `/api/trailers/filters` and +`/api/trailers/random`, Acquire status `/api/acquire/status`, and admin OIDC +readiness `/api/oidc/status` are direct FastAPI handlers on one router app. +Member-safe remote-play status `/api/remote-play/status` is also served there. +Admin settings-hub badges `/api/settings/module-status` use it as well. +The admin library-health summary `/api/health/library` is also native. +The complete admin quality-profile API family `/api/quality-profiles` now uses +FastAPI for list, create, save, activate, detail, update, delete, and score +actions, including CSRF on writes and the legacy bare JSON responses. +The admin statistics read `/api/admin/statistics` also dispatches through +FastAPI, keeping the existing aggregate projection, admin session/token-scope +checks, envelope, no-store response, and HEAD behavior. +The admin Identify search `/api/admin/games_search` also dispatches through +FastAPI. It preserves the admin-only full-disk-path projection, query limit, +empty-search response, and legacy error envelope. +Member command-palette suggestions `/api/palette/suggest` also dispatch +natively, retaining the signed-session member gate, ACL-filtered played and +favorite projections, and bounded result limit. +The admin metadata-provider configuration `GET`/`HEAD`/`PUT` +`/api/admin/integrations/metadata-providers` also dispatches through FastAPI, +preserving the provider projection, partial update validation, CSRF, and admin +gates. +The member licensed-catalog report, admin cache-status read, and admin IGDB +refresh action also dispatch through FastAPI with the existing member/admin +scopes and cached data contract. Refresh retains CSRF, input validation, audit, +and sanitized upstream errors. +Storage and malware status are native admin reads, and `/api/rtc/status` is a +native member read. Challenge-solver and ambient-lighting status, config and +test actions are native admin routes; optional probes retain their legacy +behavior behind adapters. The admin AI status, config, triage, doctor-notes, +and apply-triage endpoints also dispatch natively and retain their status and +action payloads (including the unreachable-Ollama `error` field), CSRF-protected +writes, and explicit auto-apply gate. Focused tests inject adapters and make no +host, device, Ollama, or game-change calls. +The admin library-platform options endpoint is also native and returns the +existing platform enum through the shared API envelope without a database read. +The admin Arr status endpoint is native as well; its status projection and +connector readiness checks retain their legacy behavior behind the same admin +and token-scope gates. +The admin theme loading-icon catalogue `/api/admin/themes/loading-icons` also +dispatches through FastAPI and keeps the shared success envelope and admin gate. +The admin theme inventory `GET`/`HEAD /api/admin/themes` also dispatches through +FastAPI with its existing metadata projection, admin gate, envelope, and HEAD +behavior; uploads and deletes remain on their compatibility routes. +The admin metadata-search source catalogue `/api/search_metadata/sources` +dispatches through FastAPI with the same bare capability projection and admin +gate; it reads local configuration only and performs no provider search. +The admin `/api/search_metadata` provider lookup route also dispatches through +FastAPI, preserving query aliases, result limits, per-source notes, and the +admin gate. Provider calls remain behind their existing adapters. +Companion capability and lifecycle reads, heartbeat, command queue/poll, and +command acknowledgment/release APIs also dispatch through FastAPI. Lifecycle reads +and command polls retain the `read:library` gate; lifecycle synchronization and +command acknowledgment retain their Bearer-only rules; browser heartbeat and +command queue writes retain CSRF, scope and ACL checks. +Announcements (including admin-only draft visibility and CSRF-protected +creation) and the gaming/free-game member feeds also dispatch natively. Provider +calls stay behind adapters so contract tests never issue outbound requests. +Member Discover feed, row-window, zone, genre-hub, surprise, pins reads and pin saves now +dispatch directly through FastAPI. The release calendar also dispatches +through FastAPI while retaining its bounded query and empty-provider fallback. +They preserve feed tokens, first-value pagination and filter +parameters, multi-segment genre paths, error envelopes, and signed-session +redirect behavior. Pin saves retain CSRF and partial-update semantics. +Member activity feed, game search, game-detail, editions, more-from, versions, +and freshness reads also dispatch through FastAPI. The detail reads keep the +existing game ACL and payload builders; versions retain the `read:library` +Bearer scope and path-redacted projection. The on-demand freshness check is a +CSRF-protected native write whose provider/data work remains behind an adapter. +The filter bundle and library-platform catalogue also dispatch natively while +retaining the member-specific library projection and optional completion flag. +Playtime summary/per-game reads, session start/heartbeat/stop actions, and the +ACL-protected playtime share-card SVG also use FastAPI; they retain per-game +visibility, session ownership, body validation, and browser CSRF. The shared game-details image projection builds +the same static URL when called without a Flask request context. +Favorites, saved filters, social friendship mutations, Acquire search/send, and +the VR catalogue/profile APIs now use the same native request-local SQLAlchemy +scope. Their query/body validation, ACL and librarian checks, CSRF, Bearer +scopes, ownership rules, URL validation, payload envelopes, and HEAD behavior +remain in the route adapters. These are migration slices; they do not remove +Flask initialization or extension ownership. +The member account API now dispatches through FastAPI: summary, invites, +preferences, stock-avatar selection, multipart avatar upload, password change, +and personal-token list/create/revoke. Existing signed-session, remember-cookie, +and route-specific Bearer behavior is retained. Mutations validate the existing +Flask-WTF CSRF token; password changes refresh the current signed session and +remember cookie with the new password fingerprint, and avatar writes preserve +the existing file validation, commit, rollback, and old-file cleanup services. +Preferences keep the current choice lists and WTForms validation contract. +Preferences, stock-avatar selection and multipart avatar upload use the native +request-local SQLAlchemy scope. Account summary and invite routes also use +request-local sessions and framework-independent response envelopes. Avatar +URLs use an explicit static URL builder; invite mail uses a quiet SMTP sender +with the existing reachability preflight and explicit audit actor. This account +API slice does not remove the Flask compatibility runtime. +Game detail, search, editions, versions, freshness, screenshot, image, and +assist APIs also run in request-local SQLAlchemy scopes. The assist projection +receives its enablement and storage configuration explicitly; the legacy Flask +assist routes retain their current-context defaults. Freshness audit events use +the authenticated actor id directly. +The member card action routes for favorite and completion status now use +FastAPI, along with the paged favorites projection, member role/username/icon +metadata, and the five standalone filter taxonomies. Social member profile and +comparison reads retain `read:social`, visibility filtering, and their existing +response shapes. VR catalogue/detail reads preserve the feature flag, page +bounds and game ACL; VR profile writes preserve librarian/admin-token checks, +browser CSRF, body validation, and the HTTP(S)-only profile URL contract. Batch +favorite, status, wishlist, freshness and artwork-refresh actions also dispatch +natively with their legacy caps and partial-success results. Favorites, saved +filters, social friendship mutations, VR routes, Acquire actions, and batch +freshness/artwork refresh now use request-local SQLAlchemy scopes and +framework-independent API envelopes; provider, database and filesystem effects +remain behind adapters where applicable. +The member locale API (`GET`/`HEAD`/`POST /api/locale`) also dispatches through +FastAPI, preserving supported-locale values, JSON and form input, browser CSRF, +the signed-session locale update, the `gt_locale` cookie, and security headers. +Its SQLAlchemy adapter uses the request-local native scope. Flask keeps the +legacy URL rule registered for route-census comparison, while ASGI dispatches +the request to the native FastAPI handler. +Discover section, row, zone, genre-hub, surprise, and pin reads also use native +request-local database scopes. They keep the existing builders, first-value +query parsing, response shapes, session redirects, security headers, and HEAD +behavior; their configured JSON provider is called without Flask request state. +The unused Flask-Babel extension was removed after a source call-site audit +found no translation calls or consumers of Babel's selected `g.locale`; the +locale values and normalizer live in framework-independent +`oneirodex/utils/locale_core.py`. +Member game-health detail `/api/health/games/{game_uuid}` keeps its per-game +access check on the native path. +They use the shared request-auth boundary and keep their existing query, +readiness and payload services plus SQLAlchemy access inside one explicit Flask +app context during the transition. Anonymous requests retain Flask-Login +redirect, flash, and signed-cookie behavior; admin OIDC access also preserves +role and token-scope checks. +Public static assets also use the direct ASGI file handler; private paths remain +denied and member BIOS still checks the existing signed session. These paths +skip Flask request dispatch; lifespan startup initializes Flask for +configuration and extension compatibility. + +The ASGI activity/event streams now check event visibility and child-role +eligibility in request-local ORM scope without pushing Flask app context. Their +signed-session ID resolver uses the framework-independent Flask-cookie codec +and active-user/password-fingerprint checks. + +ASGI ZIP and ROM downloads read ownership, game ACL, and download records in +request-local ORM scope. Download identity uses the signed-session codec or +native Bearer resolver, preserving fingerprint validation, token expiry and +scope checks, child restrictions, and explicit Bearer precedence over cookies. + +The zero-Jinja runtime migration is incomplete. The authored template directory, +template render calls, and direct Jinja imports have been retired. The legacy +route inventory now has zero unmatched method/path pairs, and ASGI sends final +dispatch to FastAPI; the WSGI fallback and `a2wsgi` dependency are retired. +`create_app()` still constructs Flask, native session/CSRF adapters preserve +signed-cookie compatibility, and Flask-SQLAlchemy still owns mapped models and +legacy service fallbacks. These are the remaining strict runtime gates. + JSON errors go through `api_ok` / `api_error` (`oneirodex/utils/api_response.py`). Eleven sites stay off that helper on purpose — [api-envelope-keeps.md](api-envelope-keeps.md). ## SPA fetch -Admin, member, and ops sit on `createBrowserRequester`: `credentials: 'include'`, `X-CSRFToken` on mutating verbs, `onUnauthorized` → `/login`. Wrappers keep their exported names. Member collections CRUD now calls `createCollectionsApi` through that same requester (`memberResource`); search still uses `getJson` on `/api/search`. Other resource groups still call `getJson`/`postJson` directly. +Admin, member, and ops sit on `createBrowserRequester`: `credentials: 'include'`, `X-CSRFToken` on mutating verbs, `onUnauthorized` → `/login`. Wrappers keep their exported names. Member collections CRUD calls `createCollectionsApi`, wishlist calls `createWishlistApi`, and notifications use the shared requester for inbox, read-state and preference operations. Those families, social status/friend reads and mutations, the wanted-update list, freshness inbox/store-search/scan, and Acquire search/send dispatch through FastAPI under ASGI and preserve browser sessions, CSRF for browser writes, role/ownership or token-scope checks, API envelopes and security headers. Acquire keeps its librarian gate, feature flags, outbound-URL validation, and sanitized provider errors. Social and notification Bearer reads require `read:social`; their writes use `write:social`. Other resource groups still call `getJson`/`postJson` directly. Member leftovers that are not JSON verbs: -- `preferences.ts` — HTML `POST /settings_panel` (FormData) - Activity / scan toasts — `EventSource` (`/api/activity/stream`) +The SPA preferences dialog uses `GET` and `PUT /api/account/preferences`; its +choice lists come from shared theme, icon, and font registries and the retained +WTForms validation contract. + Request bodies adopt `@validate_body` **file-by-file** — [pydantic-adoption.md](pydantic-adoption.md). Partial-success batch routes use `@validate_batch_body` so the 422 keeps `updated` / `skipped` / `errors` / `limit`. Do not wrap those with the flat helper. ## Browse filters (INSP-3) @@ -65,3 +552,238 @@ Runtime env is **`ONEIRODEX_*` only**. `GT_*` is not read. `LEGACY_NAME = 'GameT - Shims: `oneirodex/utilities.py`, `oneirodex/utils/functions.py`, `oneirodex/utils/game_core.py` - Envelope keep-list of 11 - Mega pages (GameDetails, Library, Chat, Discover) stay typed in place — no decomposition PRs + +## P17 native admin API progress + +The FastAPI compatibility router currently owns the HLTB coverage, single-game, +and bulk refresh actions; Arr configuration, indexer catalogue, CRUD, bulk, +preset, search, download and hardlink preview/apply actions; SMTP and IGDB settings/test actions; custom chat emoji +listing and deletion; admin theme inventory/upload/delete; and admin reference-set listing, multipart upload, and +deletion. Reference-set uploads use the pinned `python-multipart` parser, cap +file reads at 32 MiB, check CSRF before authorization, and preserve the +replacement-confirmation response. The companion `/api/reference-sets` route +preserves its signed-in member list response and admin upload/delete shapes. +The same router also serves the member set-completion report and admin +reference-set rehash and dry-run repair-preview actions, the complete Quality +Profiles API family, and Discover pin saves; tests inject fake +adapters so these workflows are not run against the local library during +verification. +The native game API router also owns the arcade input-family patch, keeping its +librarian/token-scope gate, CSRF check, game ACL and closed vocabulary outside +Flask request context. It also serves the next-custom-IGDB-ID read and the +admin bulk freshness refresh action and librarian game-to-library move through +request-local database scope. Native library catalogue, detail, and watch +reads preserve member ACL and count projections; admins can read library +details, reorder and batch-edit libraries, and librarians can update the library watch +flag, through the same request-local router. The FastAPI notifications router also owns +the admin test event publish action, alongside the notification-bus test. The +native chat emoji catalogue serves both the member and admin catalogue reads. +The member chat read router now also serves channel creation, channel and space +lists, message reads and search. Message reads retain the legacy read marker; +channel creation, mute, archive, leave and open-DM actions use native CSRF +validation and the `write:social` token scope. Message posts and reactions are +also native. Attachments, custom emoji writes and remaining space APIs still +use the compatibility router. Chat attachment downloads now use a native +FastAPI file response after member/channel authorization and safe directory +resolution; upload remains on the compatibility route. The chat-space list, +create/channel actions, member and invite management, join flow and voice-room +lookup now use the native router. Chat attachment upload and custom emoji writes +also use native FastAPI multipart handlers. Storage helpers accept explicit +upload settings so these routes do not need Flask's application context; the +multipart bodies are capped before parsing and the file parts are closed. +The eight detail-layout APIs (install default, member override and named +presets) also dispatch through FastAPI with request-local database scope. +These routes still initialize Flask in the outer ASGI lifespan and do not imply +that P17's strict runtime gate is complete. + + +P17 adds FastAPI reads for store-ownership provider policy, stored connections, summary, review titles/candidates, match/undo and sync cancellation, plus emulator catalogue/per-platform options. The ownership review reuses owner-scoped, ACL-filtered and revision-checked service functions; provider sync calls are not performed by these read APIs. The emulator routes retain member authentication and generic unsupported-platform errors. These additions still use Flask configuration/auth/database compatibility objects during outer ASGI startup and do not close the no-Flask runtime gate. + +The emulator settings slice adds admin-only profile reads and saves through the same native router; browser writes retain CSRF checks and the existing profile validation/storage service. + +The native emulator settings router also serves the admin preferred-core catalogue and browser-player defaults, including CSRF-protected configuration writes; it reuses the existing validation and settings services. + + +The native library freshness action also passes explicit app configuration into the shared freshness service, avoiding a Flask context for its root checks. +The native Steam metadata backfill now uses the existing request-local session directly, without wrapping provider hydration in Flask context. The background library-wide force-refresh worker remains Flask-bound. + +Its route regression tests exercise the real native request-local SQLAlchemy scope on in-memory SQLite, with Flask app-context entry forbidden. + +The unmatched-folder mutation router now runs its request/auth/CSRF handler inside +only the native request-local database scope, without pushing a Flask app context. +Its adapters also avoid nested contexts for kinded-game creation and explicit-actor +audit, retry matching, and guarded update placement. It still receives Flask config, +URL-map, and login-manager compatibility objects; the outer Flask lifecycle and +strict runtime gate remain. + +The native system API router also handles allowed-root path availability checks +and admin allowed/ignored file-type CRUD. It preserves the legacy response +payloads, admin/token scope rules, CSRF order, extension validation and safe +existence checks. These route families still share the existing auth/config +compatibility bootstrap; they do not remove Flask startup or extension ownership. + +Admin library-root inventory and folder browsing now use the native router, +with the existing signed-session/admin gate, configured-root resolver, +allowlisted-path checks, and GET/HEAD response behavior. Ownership connection +and import actions (connect, disconnect, sync, sync-all, and CSV) also dispatch +through FastAPI with CSRF-before-auth and the existing store validation and +payload compatibility. Both slices use adapter boundaries for focused tests; +they still depend on the shared Flask configuration and session compatibility +objects during startup. + +Emulator cheat/save and BIOS APIs, the game-server registry, and game-mod, +catalogue, and profile APIs are also mounted in the native router. Emulator +multipart uploads are bounded before parsing. These endpoints retain the +existing auth and ACL rules and keep writes and probes behind data adapters, +but the overall application still builds Flask for configuration and extension +compatibility. Registered HTTP routes dispatch through native ASGI/FastAPI. + +Unmatched-folder listing, duplicate comparison, fix-log reads, and CSV/JSON +exports also use FastAPI. Their query aliases, post-region pagination, +projection fields, admin redirects, and download headers remain compatible; +mutation and maintenance endpoints are separate and remain on their current +routes. + + +Unmatched-folder mutation routes now dispatch through FastAPI as well: soft-name edits, bounded batch triage, duplicate-fix logging, mark-kind, retry matching, and guarded update placement. Authorization, CSRF order, and source/destination library-path checks are retained, with external and destructive effects behind test adapters. This is API parity work; it does not retire Flask startup or extension ownership. + +Library inspection and preview APIs now include native FastAPI handlers for manual-identify preview, leaf-library proposals/import preview, proposal listing and scanning, and library-doctor dry-run. Path checks and parsing are adapter-backed in focused tests; remaining library-tools mutation and scan-queue routes still use compatibility dispatch. Unmatched maintenance now also covers duplicate reclassification/backfill and bad-match feedback. These endpoint migrations retain Flask configuration/session compatibility during startup. + +The native library-tools write slice now covers rename preview, proposal approval, and doctor proposal writes; each route uses the existing admin/session/token checks, CSRF validation, and adapter-backed side effects. Remaining scan-queue and filesystem mutation routes still use compatibility dispatch. + +Admin library creation, batch scans, and batch deletion now use FastAPI routes with explicit admin/token authorization, CSRF checks, caps and typed deletion confirmation. Durable database, image, scan and deletion-job work remains behind adapters while route parity is established. + +Manual-identify preview/queue, support-ticket list/detail reads, and the theme-font catalogue now have native FastAPI dispatch. The scan queue preserves the idle preview and explicit busy-scan choices; support reads remain browser-session-only and owner-scoped for members. The font inventory keeps its existing `fonts_dir` response field pending later exposure review. + +A source-level API route comparison is maintained as an estimate only: it cannot establish that each FastAPI router is mounted behind the correct ASGI method gate or that behavior matches. Runtime dispatch checks remain necessary for each migrated family. + +Read-only provider and patch-catalog registries plus the admin integrations inventory now use native FastAPI dispatch, preserving their legacy response shapes and authorization. Provider calls were not part of these catalog reads. The server-local path fields remain for parity and need review in the planned security scrub. + +Artwork-provider searches and the admin artwork-apply action now dispatch through native FastAPI routes. Artwork writes retain signed-session CSRF and admin/token-scope checks, bounded input validation, provider and image-kind restrictions, and the existing artwork service behind an adapter. Launcher import writes are also native and remain upload-bounded and feature-gated. Flask startup and ORM ownership remain active while semantic parity and dependency retirement continue. + +The admin attract-mode settings API now uses native FastAPI dispatch for reads and saves. Typed schema validation, CSRF-before-auth, admin token scope, filter-option projection, and audit/cache updates remain in place; the surrounding app still uses Flask for startup and uncaptured routes. + +Admin feature preferences and challenge-solver configuration now also dispatch natively. The migration preserves the fixed seven-field preference contract, read-only process flags, challenge validation and safe probe projection, with the operator action still routed through its existing adapter. + +The admin scan-filter catalogue also uses native FastAPI reads and writes. It keeps the legacy pattern taxonomy and case-sensitivity rules, validates regular expressions before persistence, and gates mutations with signed-session CSRF and admin authorization. + +The local plugin catalogue and detail endpoints now use native FastAPI reads with admin and token-scope checks. Plugin discovery stays read-only and uses the existing catalogue service. + +Single-library admin edits now use native FastAPI dispatch for JSON and multipart submissions. The editor retains CSRF and admin authorization, bounded image conversion, metadata validation, and the existing audit behavior. + + +The admin single-library scan endpoint now dispatches through FastAPI with the same signed-session admin gate, CSRF ordering, configured scan-root validation, queue policy, schedule fields, and scan flags. The scan service remains behind an adapter for focused tests. This route migration does not retire the Flask-backed ASGI lifespan or SQLAlchemy ownership layer. + + +`POST /api/admin/libraries/refresh_all` also dispatches through FastAPI. It preserves FIFO job creation, the legacy busy check before enqueue and promotion when idle, plus the explicit force-parallel worker. These routes still use compatibility configuration/session/database objects until the primary runtime migration is complete. + + +The admin scan-job status poll now dispatches natively for GET and HEAD. It retains server-side filters, timing projections, queue-position lookup, and the best-effort idle drain. The route uses the existing admin/session compatibility callbacks while the main Flask lifecycle remains. + +The manual-identify editor create and update APIs now dispatch through FastAPI. +They retain admin and token-scope checks, browser CSRF, path/library validation, +duplicate detection, metadata writes, unmatched cleanup, audit events, and image +refresh scheduling. Folder-size and NFO helpers accept explicit allowed roots so +the native adapter does not need Flask request-local state. The native runtime +context now supplies a SQLAlchemy-only context manager for background work; +scheduled scan and queue-drain workers receive an explicit native session +scope. Flask services and package dependencies remain to be retired. + +Library inspection and manual-identify preview adapters now use the request's +native SQLAlchemy session and their explicit app configuration directly; path +resolution, scan-mode selection, and candidate enumeration no longer push Flask +app contexts. Folder enumeration also avoids writing a flash message when it is +called outside a Flask request. + +Unmatched-folder row projections now live under `oneirodex.utils`, shared by +legacy and native routes. FastAPI adapters no longer dynamically load a Python +module from the legacy API route tree; compatibility exports remain for older +imports while their callers transition. The native unmatched read routes pass +app configuration into cover URL projection and no longer push Flask app +contexts for list, export, or duplicate comparison responses. Feedback routes +also run auth/CSRF dispatch without Flask app context; explicit-actor audit +writes and native library/game lookups use the scoped SQLAlchemy session. +Library-tools link, rename, and doctor-apply actions also use the scoped session +without app-context pushes; link audit events carry the authenticated actor ID. + + +The scan module's status poll and desktop path-info endpoint now use FastAPI dispatch. The latter only reports the supplied path and its existence metadata to an authorized admin; filesystem opening remains a desktop companion action. + + +The admin Newsletter SPA now uses native FastAPI routes for history/readiness, detail, and send. Reads remain no-store and redact historical transport errors; sends retain CSRF/admin gates, recipient validation/deduplication, and the existing delivery service behind an adapter. + + +The IGDB availability lookup now uses a native FastAPI read route and the existing game lookup helper. It keeps member authentication and the legacy availability response without calling the IGDB provider. + + +Member email-confirmation API requests now use FastAPI with the existing timed token format and confirmation state transitions. The React auth shell keeps its current JSON contract; login, registration, password-reset APIs, and the Flask startup gate remain separate work. + + +The admin Server Settings React API now has FastAPI GET/HEAD/PUT dispatch with safe projection, scoped admin access, CSRF-protected partial updates, and no-store reads. Flask startup remains during strict framework retirement. + + +The React auth shell password-reset confirmation endpoint now uses FastAPI with the existing signed-session compatibility, CSRF, token-expiry, and password-validation contract. Reset-email delivery remains on the compatibility route while its rate-limit and mail side effects are migrated separately. + + +The React auth shell password-reset request and confirmation endpoints now use FastAPI with CSRF/session compatibility, per-IP throttling, non-enumerating responses, and token/password rules. Mail and persistence remain behind an injectable boundary during route parity work. + + +The React auth shell password-reset request and confirmation endpoints now use FastAPI with existing session/CSRF compatibility, per-IP throttling, trusted proxy handling, non-enumerating responses, and token/password rules. Email delivery and persistence remain isolated behind the native adapter during migration. + + +The member auth APIs for login and registration now use FastAPI. They retain the existing session/remember-cookie format, credential throttling and account-state rules, invite/whitelist transaction, and confirmation mail contract while the remaining compatibility runtime is migrated. + + +The static member auth shell login/register/password-reset APIs and the `/logout` redirect now dispatch through FastAPI, preserving signed sessions, CSRF, remember cookies, account policy, and invite/reset flows. + + +The member authentication page URLs serve the built React shell through FastAPI for GET/HEAD, preserving signed-session redirects, token checks, flash notices, and no-store behavior. Classic login, invite registration, reset-request, reset-confirmation, invite creation, and invite deletion form POSTs now use native FastAPI adapters with form CSRF and existing session-cookie behavior. `/user/invites` GET/HEAD retains its signed-session and admin warning behavior; invite mutations use the existing account-invite data adapter and preserve the library redirect/API response contracts. Native account invite APIs also carry a validated request origin into invite URL construction, preferring configured Site URL and retaining the trusted-host allowlist/private-network rules without a Flask request context. The opt-in `/demo` sign-in also uses a native user lookup and writes the same fresh signed session before redirecting to Discover. + + +First-run setup page GET/HEAD routes serve the built React shell through FastAPI with step redirects, signed admin access, flashes, and setup-step initialization preserved. The JSON transition API and classic form POST routes now share native FastAPI dispatch and transition logic. + + +The runtime config builder is framework-independent and shared by the compatibility factory. FastAPI creates its native SQLAlchemy engine directly from that config and disposes it during ASGI shutdown, rather than reading Flask-SQLAlchemy's private engine registry. Native request and worker scopes expose that config through a context-local accessor and bind the native SQLAlchemy session; service helpers use a small compatibility fallback only when called from the legacy Flask app. Normal ASGI startup no longer constructs Flask; Flask-SQLAlchemy remains installed for compatibility services and legacy callers. This boundary change does not complete strict zero-Jinja runtime removal. + + +FastAPI's force-refresh coordinator and per-game scan workers receive the same explicit native DB scope. Scan, identify, enrichment, image, and scan-only filesystem/filter/quality/catalog helpers resolve persistence through the native session facade when that scope is active; legacy callers continue to fall back to Flask-SQLAlchemy. Importing the scan, identify, enrichment, and image modules together no longer loads Flask/Jinja. Malware policy receives explicit runtime config, and local metadata reads/writes accept explicit allowed directory bases. Native enrichment and scoped image download helpers avoid their own Flask context pushes. Native force-refresh and per-game orchestration also skip their Flask app-context pushes when the native scope is supplied; legacy worker entry points keep their context behavior. Some downstream image helpers and other image entry points still require Flask context, and no real library scan was run to validate the full path. These remain runtime migration gates. + + +The scan orchestration module itself now imports without Flask/Jinja: legacy form dependencies load only when their Flask handlers run, and the worker accepts an explicit app plus database-scope factory. Native worker session cleanup supports direct SQLAlchemy sessions. Native forced refresh supplies its app and database scope explicitly and no longer pushes an outer or per-game Flask app context. Legacy scan callers retain their original context setup. Full scan execution and background queue-drain behavior still need isolated parity evidence. + + +The FastAPI worker scope also reaches game admission, identify, deferred post-identify enrichment, and image persistence/download coordinators. Their DB sessions use the direct native engine. The worker context reduction is limited to native force-refresh and its per-game orchestration; image refresh and other legacy/background entry points still use Flask app contexts. + +The root install manifest (`/manifest.webmanifest`) now reads site branding through the native request-local database scope and uses runtime security headers. GET/HEAD requests are handled before Flask initialization; this removes another first-request dependency on the Flask app context while preserving the public cache policy and manifest payload. + +The legacy `/restricted` page now uses FastAPI redirect handling. Anonymous requests retain the login message and `next=/restricted` target; authenticated requests continue to `/welcome`, with cookie and security-header behavior kept in the native handler. + +The `/downloads` URL now resolves to the authenticated member shell directly, where the existing React Downloads page already owns the view. The legacy `/playromtest` URL also uses the member shell and retains its informational flash message. + +`/play_game/` now uses FastAPI for the existing browser-play decision. The native route retains the library ACL check, browser-play URL, missing/inaccessible-game redirects, and user-facing flash messages. + +`/game_details/` now uses the native authenticated member shell. It validates the UUID, checks game existence and the user’s library/content ACL before serving the React shell, preserving the legacy 404 and 403 outcomes. + +The GET/HEAD compatibility redirects for `/settings_profile_view`, `/settings_password`, and `/settings_panel` now run in FastAPI and retain their existing account/preferences destinations. Their mutation methods remain on the compatibility path while those flows are migrated. + +The main `/library` page now builds its React shell in the native member router. It retains default preference creation, URL-over-cookie filter precedence, library-name resolution and flash redirect, user sort/page preferences, and the catalog/scan/unmatched counts used by empty states. + +The native `/library` shell also preserves first-visit behavior: if the authenticated user has no preferences row, the request creates it through the native session before building the shell. + +The authenticated `/dev/styleguide` page now runs through FastAPI. Its renderer accepts explicit user, session, CSRF, asset-root, and static-path inputs, so the native path keeps the theme/icon specimen and flashed messages without Flask request globals. + +The member download polling endpoint `/check_download_status/` now uses FastAPI and the native session. It keeps per-user ownership, invalid/missing status envelopes, cache policy, and security audit logging. + +The legacy GET form of `/delete_filter/` redirects through FastAPI to the admin Scan Filters page with its warning flash. Its CSRF-protected POST deletion now also runs through FastAPI, commits through the native session, flashes success, and returns to the same page. + +The legacy POST `/delete_download/` form now uses FastAPI with a bounded form parser, CSRF validation, and a row lookup scoped to the signed-in user. It keeps the `/downloads` redirect and removal flash. + +The admin POST `/delete_download_request/` form now uses FastAPI with CSRF and admin/token-scope checks, preserves the success/not-found flashes and `/admin/manage-downloads` redirect, and deletes through the native session. + +The artwork refresh progress poll (`/check_image_refresh_progress/`) now uses the native admin session and role checks. Cached progress records are returned verbatim (including a job's `error` state); a missing record retains the existing `not_found` envelope. Focused native admin progress and route-matcher tests passed 7/7. + +The library deletion progress paths now use native admin auth: `/check_deletion_progress/` returns the existing raw worker state or the legacy not-found envelope, and `/delete_library_progress/` streams the same SSE status events, headers, polling cadence, and terminal cleanup. The active completed-job test verifies the stream framing and state cleanup without starting a deletion worker. + +Legacy admin scan-job cancel, delete, and clear form actions now dispatch through FastAPI. The native routes use bounded form parsing, Flask-compatible form/header CSRF, existing admin/session/token-scope checks, native DB sessions, session flashes, and redirects to `/admin/scan_management?active_tab=jobs`. Restart now uses the shared scan-queue transition and passes the explicit native DB scope into its worker, so the worker and subsequent queue drains do not require Flask app contexts. + +The scan queue module now imports without Flask/Jinja and resolves Flask's `current_app` lazily only for legacy callers. Native restart work imports the framework-free scan orchestrator directly, and the DB scope follows the restarted worker and queue promotion path; the fake-backed worker test forbids Flask app-context use. No real scan was run. diff --git a/docs/dev/ci-gates.md b/docs/dev/ci-gates.md index dbb59002c..878886aae 100644 --- a/docs/dev/ci-gates.md +++ b/docs/dev/ci-gates.md @@ -11,7 +11,7 @@ What remains is this: the gates as they stand, and the two that are still owed. | Job | What it gates | |---|---| -| `pytest-core` | **`tests/ -m "not integration"` minus a named `--deselect` list** (flipped 2026-09-17) + `--cov=oneirodex --cov-fail-under=65`, then three ratchets: `api_envelope_lint`, `print_lint`, `get_json_lint`. A new test file is gated by default | +| `pytest-core` | Builds the member, auth-pilot, and admin React shells, then runs **`tests/ -m "not integration"` minus the named `--deselect` list**, coverage (65% floor), and the API-envelope, print, and get-json ratchets. A new test file is gated by default | | `alembic-check` | Fresh Postgres → `alembic upgrade head` → `alembic check`. Fails hard on any model/migration drift | | `lint` | Repo-root `npm ci`, then `npm run lint` **and** `npm run format:check` (Prettier has failed a PR on its own — do not skip it) | | `member-app-vitest` · `admin-app-vitest` · `ops-glance-vitest` | Per-SPA vitest + `npm run typecheck`. `ops-glance` also builds. `admin-app-vitest` carries `css-token-lint`, `any_lint`, `od_btn_lint` and `component_size_lint` | @@ -25,35 +25,22 @@ repo root — see [ADR 0008](../adr/0008-npm-workspaces-single-lockfile.md). ## Owed -### 1. ~~The `pytest-core` hand list should become `-m "not integration"`~~ — done 2026-09-17 +### Marker-set gate status -**Measured:** the marker set is **3,792 tests in 11m24s** on `ubuntu-latest` (PR #118's timing job), 474 deselected by the marker. Flipped in the PR after it; `timeout-minutes` 20 → 30 for coverage overhead. +The marker-based gate replaced the hand-enumerated test selection on +2026-09-17. The CI job builds the three React bundles consumed by Python route +tests before running pytest; without those artifacts, shell routes can fail +with 503 responses even though the Docker image includes the bundles. -**The discipline that replaces the hand list:** the `--deselect` lines in `ci-tests.yml` are the exception list — the known failures from [test-suite-failures-2026-09-16.md](archive/test-suite-failures-2026-09-16.md) as they reproduce on the runner (31; three are runner-only). Remove a line when its test is fixed. **Never add one to turn a red PR green** — that is the hand list growing back under another name. +The `--deselect` arguments remain a narrow, named exception list. Remove an +entry when its test is fixed; do not add exclusions to turn a red run green. +The current 35-minute timeout allows the marker suite and coverage reporting +to finish on the hosted runner. Treat workflow evidence for the current commit +as authoritative; older timing and coverage measurements below this section +are historical and must not be presented as current results. -**Why it mattered, concretely:** the 2026-09-16 full run found **42 failures**, -and **not one of their files is named in the hand list**. The list gates nothing -it does not name, so a PR can break a file it never runs. See -[test-suite-failures-2026-09-16.md](archive/test-suite-failures-2026-09-16.md). - -**What is already done:** the `integration` marker is declared in `pytest.ini` -and applied to **32 modules** (the scan pipeline, external provider adapters — -IGDB / Steam / SteamGridDB / SMTP — and the worker/heartbeat/watch/LiveKit/ -cover-render modules). None of them are in the hand list, so tagging them took -nothing out of the gate. - -**What blocks the flip:** `-m "not integration"` currently selects ~3,699 of -~4,450 tests. The local full run takes **48 minutes**, though that is NAS I/O -and a per-test Flask fixture, not runner hardware. Before flipping, measure the -marker set on a runner (empty Postgres, no SMB) against the 20-minute budget, -and tag more modules from that timing data if it does not fit. Flip only when -the measured number is known — not on the assumption that CI is faster. **The `pytest-marker-timing` job now produces that number on every PR.** - -### 2. ~~`--cov-fail-under` sits below the real number~~ — retuned 2026-09-17 - -The marker set reports **68%** line coverage (PR #121's run) against the hand -list's 38%. Floor set to **65** — the same 3-point slack for environment -variance. Raise it as coverage climbs; it only ever moves up. +The 65% coverage floor and three ratchets are enforced after pytest succeeds. +Thus a red pytest run provides no evidence that those later gates passed. ## Conventions that outlived the wishlist diff --git a/docs/dev/pydantic-adoption.md b/docs/dev/pydantic-adoption.md index f7f3af7f8..047b62eae 100644 --- a/docs/dev/pydantic-adoption.md +++ b/docs/dev/pydantic-adoption.md @@ -127,6 +127,16 @@ Flask blueprints (`/api` for `routes_apis/`). | `routes_admin_ext/art_studio.py` | `POST /admin/api/art-studio/preview` (`art_studio_preview`) | `ArtStudioPreviewBody` | | `routes_admin_ext/art_studio.py` | `POST /admin/api/art-studio/generate` (`art_studio_generate`) | `ArtStudioGenerateBody` | | `routes_admin_ext/art_studio.py` | `POST /admin/api/art-studio/apply` (`art_studio_apply`) | `ArtStudioApplyBody` | +| `routes_admin_ext/attract_mode.py` | `PUT /api/admin/attract-mode/settings` (`admin_attract_settings_api`) | `AttractModeBody` | +| `routes_admin_ext/features.py` | `PUT /api/admin/features/settings` (`features_settings_api`) | `FeaturePreferencesBody` | +| `routes_admin_ext/features.py` | `PUT /api/admin/features/challenge` (`features_challenge_save_api`) | `ChallengeSolverBody` | +| `routes_admin_ext/filters.py` | `POST /api/admin/scan-filters` (`scan_filters_api`) | `ScanFilterBody` | +| `routes_admin_ext/newsletter.py` | `POST /api/admin/newsletters` (`newsletter_send`) | `NewsletterSendBody` | +| `routes_admin_ext/igdb.py` | `PUT /api/admin/igdb/settings` (`igdb_settings_api`) | `JsonObjectBody` + `validate_igdb_data` | +| `routes_admin_ext/settings.py` | `PUT /api/admin/server-settings` (`server_settings_api`) | `JsonObjectBody` + `validate_server_settings` | +| `routes_apis/integrations.py` | `PUT /admin/integrations/oidc` (`oidc_admin_config_put`) | `OidcAdminBody` | +| `routes_apis/integrations.py` | `PUT /admin/integrations/community` (`community_chat_settings_api`) | `CommunityChatBody` | +| `routes_smtp.py` | `PUT /api/admin/smtp/settings` (`smtp_settings_api`) | `JsonObjectBody` + `validate_smtp_data` | | `routes_arr.py` | `POST /api/arr/download` (`arr_download`) | `ArrDownloadBody` | | `routes_arr.py` | `POST /api/arr/hardlink/preview` (`arr_hardlink_preview`) | `ArrHardlinkPreviewBody` | | `routes_apis/game.py` | `POST /api/games/batch/favorite` (`games_batch_favorite`) | `BatchFavoriteBody` (`@validate_batch_body`) | @@ -174,6 +184,14 @@ Flask blueprints (`/api` for `routes_apis/`). `updated`/`skipped`/`errors`/`limit`. Now 422 `unprocessable` with the same extra keys plus `detail` naming the field. Over-limit is still 400. Happy-path `ok` is still "did every item succeed" (not `api_ok`). +- **Admin feature, scan-filter, newsletter, attract-mode, OIDC, and community + settings bodies** — schema validation now runs through `@validate_body`; + custom error handlers preserve each endpoint's existing 400 code and message. + Authorization decorators still run before body validation. +- **Server, IGDB, and SMTP settings bodies** — `JsonObjectBody` verifies the + top-level JSON object, then the existing endpoint-specific validators retain + their field rules and 400 messages. The server-settings handler applies it + only to PUT; GET remains body-free. ## Deliberately not adopted (and why) @@ -193,6 +211,10 @@ Leave these until the contract can be preserved; do not force them. - `admin_freshness_refresh` — every field optional with a default; admin authZ is done in the body, not a decorator. Nothing to delete and validation must not run before the admin check. +- `routes_apis/library.py` create/edit endpoints accept multipart image uploads + as well as JSON. They use `read_json_body()` only for the JSON branch and + retain their existing form and field validation; a JSON-only decorator would + break the multipart contract. ### `routes_apis/scan.py` (+ `scan_unmatched.py`, `scan_unmatched_edit.py` since H-D.4) diff --git a/docs/dev/theme-art-direction.md b/docs/dev/theme-art-direction.md index 22e63b1e3..ae09df622 100644 --- a/docs/dev/theme-art-direction.md +++ b/docs/dev/theme-art-direction.md @@ -19,6 +19,26 @@ A theme is not a hue. It is six things that have to agree: | **Motifs** — loading animations | shared, tinted | `css/od-loading-motifs.css`, `currentColor` throughout | | **Glyphs** — rail and chrome icons | the icon pack the preset names | `icon_pack` on the preset | +### Glyph source boundaries + +React member and admin rails use the shared catalog in +`frontend/shared/src/railIcons.tsx`; the era-specific drawings in +`railIconsEra.tsx` replace only the named rail destinations. The member app-bar +has a small local set in `frontend/member-app/src/chrome/icons.tsx`. Classic +server-side SVG markup has been retired. The framework-neutral source for shared +glyph geometry and aliases is `frontend/shared/icons/manifest.json`, with a +generated React adapter. Keep semantic `data-icon` names stable so CSS packs and +accessibility reviews can identify glyphs reliably. + +Shipped drawing packs mask only the keys listed by +`oneirodex/utils/icon_themes.py`; other rail glyphs keep their shared drawing +and receive the pack's stroke/fill treatment. Platform system marks, file-type +icons, avatars, cover art, and room illustrations are separate asset families, +not general-purpose UI glyphs. A future cross-app catalog should make those +families discoverable while retaining their different rendering and ownership +rules. Do not treat the React rail map, shared SVG manifest, platform marks, and +theme-pack SVGs as interchangeable copies of one complete icon set. + The shipped catalogue now has six redesigned presets: Mosslight, Afterglow Arcade, Graphite Studio, Signal Room, Tape Deck, and Deep Space Media. Each has its own palette, geometry, type, glyph treatment, room, avatar treatment, and diff --git a/docs/dev/ui-debt-log.md b/docs/dev/ui-debt-log.md index 724a8371d..b6b458525 100644 --- a/docs/dev/ui-debt-log.md +++ b/docs/dev/ui-debt-log.md @@ -15,17 +15,88 @@ Status: `open` | `in_progress` | `done` | `deferred` | `wontfix` | ID | Area | Symptom (human) | Why it keeps coming back | Wave | Status | |---|---|---|---|---|---| +| UID-143 | Admin Dashboard and Ops status/details | Initial load briefly claimed all systems healthy before the ops summary arrived; Ops detail rows rendered structured values as `[object Object]` | **Done 2026-10-15:** Unraid review showed `All systems healthy` during loading, then `Degraded` with disk warnings. Both status banners now remain neutral through initial loading and first-request failure. Ops detail rows format known path/log records and use a safe fallback for arbitrary objects. Dashboard/Ops regression tests pass; production build and CSS-token lint pass. | Bug scrub / Ops honesty | done | +| UID-099 | Admin Storage | Failed status was described as helpers off; page owned separate layout and had no retry | **2026-10-10:** shared Page adopted; failed status remains unknown, hides readiness/config claims and disables actions until Retry succeeds. Native UI at375/1440 verifies real gates and controlled unknown/retry/read-only/negative-preview/error states without overflow or page errors. Hardlink Apply acceptance remains separate. | P15 admin migration | done | +| UID-100 | Admin SMTP | Legacy body needed shared controls and safe API state | **2026-10-10:** shared form/input/action language, secret-safe state, Retry and draft retention; targeted API/component checks and guarded 375/1440 persistence/error captures pass. No live mail transport verification. | P15 admin migration | done | +| UID-101 | Admin Scan / match | Number/select controls lacked shared styling and failed loading lacked Retry | **2026-10-10:** shared inputs, required numbers, loading/save guards and Retry; component/native checks plus guarded375/1440 real policy persistence/refresh and controlled failure states pass. Original settings restored; no scan. | P15 admin migration | done | +| UID-102 | Admin plugin registry / Settings hub | Plugin failure kept loading visible with no Retry or named table | **2026-10-10:** distinct load/error states, cancellation, Retry and named table region; Settings keyboard/link checks. React24/native28 and guarded375/1440 registry/sort/refresh/tab/failure checks pass. Shared tab CSS now loads in both entries; repeat browser checks and screenshot inspection confirm styled tabs. | P15 admin migration | done | +| UID-103 | Admin Detail Layout / Emulators | Detail row sizing/names and mutable controls during save; save failures lacked alert | **2026-10-10:** wrapping rows, unique visibility names, shared core inputs, mutation guards and draft-preserving errors. React24/native30 and guarded375/1440 real settings lifecycle/refresh/tabs pass; originals restored. No play, matching or firmware actions. White Bootstrap rows replaced with token CSS; repeat browser checks and screenshot inspection confirm dark rows/styled tabs. | P15 admin migration | done | +| UID-104 | Admin Support / Ownership | Separate page/loading shell, weak request errors, unnamed table/action controls | **2026-10-10:** shared Page/requester, loading/retry/resolve guards, named table regions and row actions; Ownership keeps repair lock through reload. React31/native32 and guarded375/1440 real list/filter/resolve/diagnostic refresh/error Retry pass. No GitHub messages or provider sync. | P15 admin migration | done | +| UID-105 | Admin artwork pages | Separate page shell, art tabs lacked roving focus/panel associations; labels cramped on phones | **2026-10-10:** shared Page and keyboard tab associations; React54/native38 and guarded375/1440 direct-entry/refresh/query/hash/keyboard checks pass. Art controls now wrap as full labels; 375/1440 recapture and manual screenshot inspection pass. | P15 admin migration | done | +| UID-106 | Admin Attract mode | Legacy-only state/form with permissive values and no shared load recovery | **2026-10-10:** strict state/save API and shared form preserve filters/autoplay/preview; API24/native39/React9 and guarded375/1440 persistence/failure Retry pass. Populated multi-select arrays, explicit accessible select names and clean restoration verified in full375/1440 rerun. No playback. | P15 admin migration | done | +| UID-107 | Admin IGDB metadata | Legacy form needed shared state/recovery and secret-safe controls | **2026-10-10:** safe settings API/presence flag/blank-secret preservation, shared page/form/Retry and dirty-test guard; API13/native40/React6 and guarded375/1440 persistence/redaction/visibility/refresh/error checks pass. Original credentials restored; provider calls mocked. **P17, 2026-10-13:** `/admin/igdb_settings` now serves the React page from the shared Python shell; DB-free API boundary tests 13/13 and page tests 3/3 pass. Legacy JSON POST is retained; integrations still uses shared form assets. | P15 admin migration | done | +| UID-108 | Household chat emoji | Legacy upload/list lacked shared native form, named table and delete confirmation. | 11 API, 41 native and 4 React checks; real owned PNG lifecycle and Retry at 375/1440; private captures inspected. | P15 | Done (bounded native route) | +| UID-109 | Ambient lighting | Settings guide claimed Features connector controls, but current template passes ambient state without rendering those controls. | The native Features page now has Hyperion/Home Assistant configuration with write-only secrets, blank-secret preservation, read-only status and an explicitly confirmed probe that explains its light/scene effect. Focused React save test and authenticated 375/1440 visual captures pass; isolated review save/readback/restore passed, and Cancel prevented any connector request. The existing classic page remains pilot-off fallback. | P15 | Done (bounded native route) | +| UID-110 | Shared admin page identity | v2 theme removed Page headings; active narrow navigation destination could remain offscreen. | 34 focused checks, both builds and five-route 375/1440 direct/refresh, active-strip geometry, reduced-motion/focus and inspected captures passed. | P12/P15 | Done (bounded shared correction) | +| UID-111 | Features | Legacy preferences/solver/status lacked shared native controls and recovery; Save automatically probed solver. | 78 Python/4 React checks and populated 375/1440 settings persistence, strict safety rejection and Retry; original state restored. | P15 | Done (bounded native route) | +| UID-112 | Shared admin inputs | Explicit admin field hooks now override legacy typed-input styles and share token surfaces and keyboard focus treatment. | 17 focused style checks, CSS lint and pilot build passed; disposable 375/1440 Features, SMTP and Attract Mode computed-style/focus comparisons and captures passed. No settings changed; reviewer removed. | P12/P15 | Done | +| UID-113 | ROM reference sets | Legacy upload/actions/report tables lacked native shared controls, recovery and replacement/deletion confirmation. | 55 Python/5 React checks plus 375/1440 owned synthetic DAT lifecycle, preservation after invalid replacement, real read-only preview and controlled four-bucket rendering. | P15 | Done (bounded native route) | +| UID-114 | Server Settings | Shared native form retains all 28 controls; partial saves preserve unrelated settings instead of replacing the JSON. | 80 Python/4 React checks, pilot build/lints and real 375/1440 save/readback/refresh, safe nested folders, strict rejection, draft/Retry and inspected captures passed. Original columns/JSON restored; reviewer removed. | P15 | Done (bounded native route) | +| UID-115 | Discovery Sections | Shared native shelf controls retain manual search/UUID ordering, filters, visibility, order, UTC schedules and pins. | 66 Python and 10 React checks; real 375/1440 fixture lifecycle, invalid-window preservation, draft/Retry and opaque-panel captures passed. Original shelf state restored; owned fixtures removed. | P15 | Done (bounded native route) | +| UID-116 | Shared modal focus | Inline callbacks and busy guards restarted focus management during typing. | 8 shared Modal checks and real sequential typing/Escape/opener focus at 375/1440 passed; full Discovery acceptance rerun passed. | P12/P15 | Done (bounded shared correction) | +| UID-117 | Newsletter and history detail | Legacy composer/history/detail needed shared native forms, truthful delivery statuses, recovery and exact numeric deep links. | Focused Python route/native checks 75/75, React 6/6, CSS/API lints and pilot build pass. Real 375/1440 history/detail/refresh, escaped content, recipient selection, cancel, intercepted failed/successful sends, draft and Retry pass with no page errors or overflow. Reviewer/history removed; no mail sent. | P15 | Done (bounded native route) | +| UID-118 | OIDC settings | Identity settings were reachable only as a tab inside the legacy Integrations page, so replacing that route would risk other active legacy tabs. | Dedicated `/admin/integrations/oidc` native page with strict admin API, redacted secret-presence state, blank-secret preservation and Retry. 81 focused Python checks, React 5/5, CSS/API lints and 375/1440 live save/readback/deep-link checks pass; settings restored, reviewer removed, no IdP contacted. | P15 | Done (bounded native route) | +| UID-119 | Admin Downloads | Legacy request list lacked the shared table and retained full ZIP paths in rendered markup. | Native page uses the shared named table, Retry and explicit deletion confirmation. Admin API exposes a basename only; focused authorization/redaction, UI and shell checks plus 375/1440 authenticated review pass. **P17:** `/admin/manage-downloads` now uses the React admin shell directly; the Jinja wrapper and page-only script are removed, and the compatibility POST delete endpoint remains. | P15/P17 | Done | +| UID-121 | Theme authoring guide | Native Themes page linked back into a Jinja-only help route, and long code samples could be difficult to read on phones. | Read-only guide now has a native route, shared Page/panel styling, internal wrapping for code samples and a Back to Themes link. 27 focused React/AdminRoutes checks plus 375/1440 direct-entry review pass without overflow/page errors. | P15 | Done (bounded native route) | +| UID-122 | Admin Integrations overview | The overview was available as React content but not as a native direct-entry shell; provider inventory failures had no Retry. | Native `/admin/integrations` dispatch now serves the grouped overview; Retry reloads provider inventory and existing hash destinations remain available. 27 focused React/native checks, pilot build and 375/1440 authenticated review pass; provider reads intercepted and no credentials/connections tested. | P15 | Done (bounded native route) | +| UID-123 | Admin scan jobs direct entry | Scan progress/retry existed in React but the rail's Jobs destination remained inside the classic scan-management document. | ``/admin/scan-jobs` now serves the shared admin shell in the standard Flask runtime, appears in the Libraries & scans rail, names the keyboard-scrollable jobs table, and links back to existing Auto/Manual controls. 49 focused React checks, 62 native dispatch checks, pilot build, CSS lint and authenticated 375/1440 intercepted-data review pass. Full Libraries & scans parity remains open. | P15 | Done (bounded native route) | +| UID-124 | Admin unmatched direct entry | Unmatched folder triage was available only inside the classic scan-management route although the React review actions already existed. | ``/admin/unmatched` now uses `DupeGlance` in the shared admin shell and the Libraries rail points to it; the classic Flask alias redirects to its prior query route when the pilot is off. 74 focused React checks, 64 focused route/dispatch checks, pilot build and authenticated 375/1440 intercepted-data review pass. Full Libraries & scans parity remains open. | P15 | Done (bounded native route) | +| UID-125 | Admin Libraries mobile flow and Jinja action dialogs | The create form consumed the phone-height legacy shell and edit/delete depended on Jinja-owned Bootstrap bridges. | Mobile flow now scrolls vertically below create and the table scrolls horizontally. Create preserves image/watch through a multipart API and queues its first scan; edit/delete use React dialogs and existing progress. `/libraries` now has opt-in native shell dispatch while the Jinja fallback stays available. Direct `/admin/library/add` and `/admin/library/edit/` now dispatch to the native forms with JSON-backed create/edit behavior. Focused API/route tests, pilot build, CSS/button/API ratchets and authenticated guarded 375/1440 captures across the library/scan route set pass; six Library Tools panels and the import candidate/error preview were visually reviewed at both widths (34 layout captures total, no horizontal overflow). The game-edit path was also captured after its ASGI dispatch fix. The import create confirmation was cancelled and sent no create/scan request. The editor save/reload/restore check confirms no legacy POST and restores its disposable row. Authenticated 375/1440 create/delete and route flows pass; the first-scan request was intercepted, and destructive filesystem actions were not run. | P15 | Done (bounded UI parity) | +| UID-126 | Admin automatic scan controls | The scan job and library pages were native, but starting or scheduling Auto Scan still required the Jinja mega-page. | `/admin/scan-controls` now has a native React workflow for root browsing, library/mode/options, schedule, queue conflicts and links to jobs; `/api/admin/libraries/scan` accepts these fields with root resolution and path allowlist checks. Focused API/UI checks and pilot build pass. Authenticated 375/1440 browser review now exercises the Manual Identify busy-scan Queue/Force dialog using intercepted responses; neither option started a scan. Current-source 375/1440 review also submitted Auto Scan through an intercepted API response; no scan ran. Manual Queue/Force choices were intercepted at both widths. | P15 | Done (bounded UI parity) | +| UID-127 | Native library bulk-add parity | The classic tools body had folder proposal and CSV/JSON import, while the native Libraries panel exposed only single-library creation. | Native `/libraries` now exposes the existing proposal and import React flows in an **Add many** section. Confirm uses the validated `/api/admin/libraries` endpoint and its returned UUID before separately queuing the first scan; it no longer submits the Jinja form route or resolves the new row by name. Twelve focused proposal/import tests and the pilot build pass. Current-source authenticated browser review at 375/1440 rendered one valid import candidate plus a rejected family-root row; selecting the valid row opened the shared create confirmation, and Cancel produced no create or scan request. A disposable library was also created through the real API and removed through the native delete dialog; the first-scan request was intercepted, so no scan ran. A temporary zero-byte placeholder tree in the isolated review volume produced a real NES leaf candidate at 375/1440; the family root was excluded, the create dialog was canceled, and the fixture was removed. Scan submission remained intercepted. | P15 | Done (bounded UI parity) | +| UID-128 | Native suggested-fixes review | Scan proposals were only visible inside the classic Library Tools pane. | Native Libraries loads proposal sidecars on demand into a named table with loading/error/empty states and Retry. Each candidate can be separately confirmed; approval clears the sidecar and links to the existing identify form, without importing a game. Focused panel tests and pilot build pass. Current-source 375/1440 browser review with an intercepted proposal candidate rendered the table and approval disclosure; Cancel submitted no approval request. The identify and approve actions were exercised in the browser with intercepted responses; no sidecar or game record changed. | P15 | Done (bounded UI parity) | +| UID-129 | Native folder-name doctor | Tidy folder names required the classic Library Tools pane and its raw JSON output for preview and consequential actions. | Native Libraries now previews cleaned names in a shared table; editing roots, pattern or limit invalidates the preview. Saving sidecar suggestions and renaming folders require separate shared confirmations. 3 focused component checks, pilot build, CSS-token and button-language ratchets pass. Current-source 375/1440 browser review used an intercepted preview row; both Save suggestions and Rename on disk confirmations were cancelled and no filesystem mutation ran. Both destructive/write confirmations were canceled at 375/1440; no sidecar or folder changed. | P15 | Done (bounded UI parity) | +| UID-130 | Native single-game rename | Renaming a game required the classic Library Tools controls and returned raw JSON rather than a selectable path plan. | Native Libraries now searches games, previews root/media/bucket operations in a shared table, invalidates stale plans when inputs change, and requires a danger confirmation before applying checked operations. 2 focused component checks, pilot build, CSS-token and button-language ratchets pass. A temporary isolated game fixture was found and previewed at 375/1440; the proposed root rename was visually reviewed and no Apply request ran. Rename preview was checked with a disposable local fixture at 375/1440; Apply was not run. Freshness uses the separate bounded panel. | P15 | Done (bounded UI parity) | +| UID-131 | Native freshness checks | Checking store freshness for a library was available only in the classic Library Tools pane and gave no first-party guard for a large request. | Native Libraries now defaults to a 25-game batch and recent-check skip; selecting the 500-game scope requires confirmation and explains store rate limits. Results and per-game errors use named shared tables. 2 focused component checks, pilot build and CSS/button ratchets pass. Current-source 375/1440 review used an intercepted result with one update, one skipped item and one error; the 500-game confirmation was cancelled and the endpoint made zero provider calls. The provider response was intercepted at both widths, and the 500-game confirmation was canceled; no provider was contacted. | P15 | Done (bounded UI parity) | +| UID-132 | Manual Identify workflow | Folder listing and linking an existing game were available only through the Jinja scan page. | Native scan controls list candidates through an admin-only, safe-path, read-only endpoint while idle. If another scan is active, a no-side-effect conflict response opens the shared Queue/Force modal; the selection is submitted only afterward. React Identify and Edit forms use guarded API contracts for IGDB/provider search, metadata/taxonomy, provider URLs, safe path, duplicate checks, scan guard, unmatched cleanup and save side effects. Direct add/edit and image-editor entries now return the shared admin shell; query prefill and API-loaded game state remain intact. `/scan_management` dispatches native pages by `active_tab` and restores selected-library context. Focused API/UI tests, full pilot build and CSS/button ratchets pass. Temporary isolated game fixtures were listed read-only and the direct `/game_edit/` route was captured at 375/1440 after closing its outer ASGI dispatch gap; fixtures were removed. Current-source 375/1440 browser review exercised RAWG search, identify-save, existing-game link, and busy Queue/Force; every consequential request was intercepted, and no provider, database write or scan ran. | P15 | Done (bounded UI parity) | +| UID-136 | Admin roster failure recovery | Invites and Libraries could leave the operator in an error state without a consistent way to retry. | Both pages retain their shared Page heading on failure and offer named Retry actions that clear the error/loading state before refetching. InvitesPage, LibrariesPanel and LibrariesPage/ScansPage focused suites: 22 tests pass, including 503 then successful retry cases. The Invites and native Libraries failure → retry → real roster flows were captured at 375/1440 in the isolated review app with zero overflow/page errors and no writes. CSS-token and button-language ratchets pass. | P15 | Done (bounded recovery) | +| UID-137 | Native Libraries scan navigation | The React Libraries page still told admins to prefer the “classic” scan page, despite the native Scan controls route being available. | Replaced the legacy cue with a direct **Scan controls** link to `/admin/scan-controls`; route assertion added to the focused Libraries test. | P15 | Done (bounded copy/navigation) | +| UID-138 | Per-game image editor route | The game-card and image-queue edit action still opened a Jinja image editor, outside native dispatch. | `/edit_game_images/` now uses shared React chrome and existing game/image APIs. 43 focused AdminRoutes/image-editor tests and 78 ASGI/native pilot tests pass; the pilot build, CSS-token lint, button-language lint and diff check pass. Authenticated isolated 375/1440 captures verified the image roster, cover/screenshot/other-artwork sections, intercepted upload/delete, scan-running guard, and canceled IGDB refresh. The `/scan_manual_folder` compatibility redirect reaches Manual Identify at both widths. Auto Scan request fields were checked at both widths with the request intercepted; no scan ran. The desktop cover section places the portrait preview beside its controls; phone layout stacks them. The Jinja route remains the pilot-off fallback. | P15 | Done (bounded native route) | +| UID-134 | Admin AI Assist | The legacy AI module had no native route and its React destination was a titled placeholder. | `/admin/ai` now dispatches the React config, explicit local connection test, suggestion, doctor-notes and gated apply workflows. Applying a title requires confirmation and the environment gate. React/route tests, pilot build and CSS lint pass; current-source authenticated 375/1440 captures show no overflow or page errors. No Ollama request or title apply was run. | P15 | Done (bounded native route) | +| UID-135 | Admin Arr module | The legacy connector/indexer/hardlink workflow had no native route and its React destination was a titled placeholder. | `/admin/arr` now mounts native module, indexer, preset, connector, search and hardlink-preview workflows. Saved credentials remain write-only; qBittorrent enqueue and hardlink apply require confirmation, and apply reflects environment gates. Focused React/API/route checks and build pass; current-source authenticated 375/1440 captures show no overflow or page errors. No connector, enqueue or filesystem apply was run. | P15 | Done (bounded native route) | +| UID-133 | Identify page context | Manual add used the “Edit game entry” browser title even though its visible heading said “Identify game.” | Context-aware document titles now distinguish add, unmatched-identify, and edit modes; focused route regression tests pass. The deployed Unraid instance is still serving its prior title and does not expose the optional native scan-controls route. | P15 | done | +| UID-140 | Admin live-region consistency | New admin pages used local `role="status"` and `role="alert"` markup, drifting from the shared status language. | `PageStatus` now supports rich polite/assertive announcements; all admin page and widget live regions use it. The per-file status baseline is reduced to zero after the real conversion; the status-language ratchet and shared PageStatus tests pass. The full admin-app suite passes 590/590. | P15 | Done | +| UID-141 | Member game action menu and store-sync focus | P17 route review found a game-details action disclosure exposing menu roles without menu keyboard behavior; the store-sync recovery test expected focus to jump while a non-cancellable request was active. | Game Details now opens a labelled menu, supports roving Arrow/Home/End navigation, and restores focus to its trigger on Escape. Store Connections keeps focus on the store heading during an active sync and does not steal it after the user moves elsewhere. Focused suites pass 43/43; the full member suite passes 1164/1164. | P17 UX-07 | done | +| UID-142 | Auth and setup button language | Auth and first-run setup actions used raw `.od-btn` markup instead of the shared `Button` component, bypassing the zero-baseline button-language ratchet. | AuthApp and SetupWizard now use shared `Button` variants; the strict UI-kit test mock retains the real component. Auth/setup tests pass 6/6, `od-btn-lint` passes at zero raw sites, and member production build passes. | P18 QA-UI-01 | done | +| UID-139 | Admin scan filters direct entry | The scan-filter rail destination rendered a Jinja form and used a GET link for removal; regex skip patterns had no add control despite scan support. | `/admin/edit_filters` now serves the shared React manager in the optional native pilot with admin-only list/add/delete JSON contracts, CSRF-protected writes, confirmed removal, and validated regex input. The rail has one **Scan filters** destination; the duplicate link is gone. The classic route remains the pilot-off fallback. 72 focused API/pilot tests, 21 React/nav checks, 19 legacy compatibility tests, pilot build, button/CSS ratchets, and authenticated 375/1440 add/readback review pass; fixture rows were removed from `oneirodextest`. Full Libraries & scans parity remains open. | P15 | Done (bounded native route) | +| UID-120 | Admin Statistics | Legacy page used six canvas charts and a separate top-title table, with no shared load recovery. | Native page preserves all six datasets and exact headline/title values using responsive token bars and the shared named table. Admin-only envelope API, React/API/shell checks and 375/1440 authenticated data-intercepted review pass; Retry works, with no overflow or page errors. | P15 | Done (bounded native route) | +| UID-098 | Admin Remote Play | Form bypassed shared CSRF/error handling, used inconsistent inputs and allowed saving empty defaults after failed loading | **2026-10-10:** shared requester and token input classes adopted; failed load disables Save with Retry, failed save retains draft with alert. Native shell acceptance: 375/1440 real CSRF save/readback/refresh and controlled failure/retry checks pass without overflow/page errors. Test configuration restored. | P15 admin migration | done | +| UID-097 | Admin Whitelist | Registration management used a classic form/table outside the shared React component system | **2026-10-10:** native route uses shared form, status, named table scroll region, subject-named removal and shared confirmation. Guarded live 375/1440 verifies add, duplicate error, registered/unregistered status, refresh, filter, cancel and delete without overflow/page errors. Old form/template retained for rollback. | P15 admin migration | done | +| UID-096 | Admin File Extensions | Quick-add actions replaced native button semantics with list-item roles | **2026-10-10:** quick-add now uses a native named button inside each list item. File Extensions is included in the default-off native shell group; legacy route remains available. Focused component and route checks pass; guarded live 375/1440 add/filter/refresh/remove checks pass without overflow or page errors. Temporary suffixes are cleaned up. | P15 admin migration | done | +| UID-095 | Admin Invites | Quota changes required a separate classic form | **2026-10-10:** shared React form adds/subtracts quota through a validated JSON endpoint, announces saved totals and refreshes the roster. Source tests and guarded live browser acceptance recorded in the migration ledger; existing classic endpoint remains for rollback. | P15 admin migration | done | | UID-067 | Admin emulator firmware | Operators with a local firmware folder had to copy it to a server-visible collection before installing matching files | The collection scanner reads server paths, while the operator's files are on their workstation. **Done 2026-10-07:** Admin → Emulation accepts a local folder and sends only firmware names required by configured cores through the existing upload API; it skips installed files and stops on ambiguous duplicate names. The documented CLI import also has a strict preflight mode. | Firmware upload | done | | UID-068 | Admin Integrations / RetroAchievements | Docker and Unraid operators could not connect RetroAchievements without changing environment variables | **Done 2026-10-08:** Admin → Integrations → Metadata & art → Artwork & secondary has the household connection form; its tab link no longer falls through to SMTP, and repeated Server settings buttons were consolidated. Environment values retain precedence; the key is never returned to the browser. | Integrations setup | done | -| UID-069 | Library / Discover chrome | Store setup prompt sat too far below the top navigation; Library tiles did not form a centered, uniform-width group; Discover shelf arrows disappeared during tile hover, blocking backward movement | **In progress 2026-10-08:** reduced the top reserve when the store prompt is present, centered fixed-width tile tracks, and kept shelf arrows available over hovered tiles. Screenshot capture and human visual confirmation remain pending against a populated build. | UI corrections | in_progress | -| UID-070 | Member hubs, Help, trailer player, and admin dashboard | Systems intro columns looked uneven; Ways to Play left unused columns; cover art was cropped into 3:4; VR showed the retired GameTheca fallback; trailer player lacked a recognizable CRT; rail mark was small; Help lacked visible bullets and readable hierarchy; rail footer added a box; newly added dashboard widgets could be appended after hidden layout rows | **In progress 2026-10-08:** paired the Systems intro panels, collapsed empty Ways to Play tracks, standardised cover tiles to contain 2:3 artwork, resolve VR covers through game art and suppress the retired placeholder, added the generated CRT frame, enlarged the rail mark, improved shared Help list hierarchy, removed the rail footer control, and append dashboard widgets after visible content. Populated visual capture remains pending. | UI corrections | in_progress | -| UID-071 | Shared menus and admin page chrome | Dropdown menus looked unrelated and stayed open after clicking elsewhere; scan/filter actions were buried in page cards; filter page had nested frames; Themes page spacing differed from Libraries; Art Studio showed a nonfunctional generic skin label; Ops status text could be clipped by a short widget | **In progress 2026-10-08:** standardized menu surfaces and item controls, added outside-click/Escape dismissal for menu dropdowns, moved Scan Jobs actions beside the table while Unmatched/filter actions remain in the THN, removed redundant filter frames and the empty Art Studio label, aligned Themes spacing to the shared admin page scale, and raised the status widget minimum height. Populated visual capture remains pending. | UI corrections | in_progress | -| UID-072 | Scan management, admin settings, platform art and themes | Unmatched comparison/action UI is hard to read; admin pages show outlines or unused background; scan jobs is a duplicate page; platform art, avatars, navigation icons, and built-in themes need a cohesive redesign | **In progress 2026-10-08:** Auto, Manual, and Jobs now share one compact Scan page; its THN switches and jobs actions have been removed, with job actions beside the table. Also redesigned comparison as a pop-out, simplified row actions, removed redundant page frames, widened legacy admin surfaces, replaced the seven shipped avatar SVGs, added selected-only theme-aware rail motion, and replaced shipped presets with six new designs. Platform mark image files still need regeneration through Art Studio; populated visual review remains pending. | UI corrections | in_progress | +| UID-069 | Library / Discover chrome | Store prompt/header spacing, uniform Library tiles and shelf navigation | **2026-10-10:** shared header reserve measures wrapped navigation; Discover arrows stay visible above hovered tiles and quick clicks scroll before the first hover frame. Authenticated controlled-response checks at 375/1440 show the store prompt, dismissal, and a 20-title shelf scrolling both ways without document overflow or page errors. Earlier populated captures cover centered Library tiles. Live provider behavior remains separate. | UI corrections | done | +| UID-070 | Member hubs, Help, trailer player, and admin dashboard | Systems intro columns looked uneven; Ways to Play left unused columns; cover art was cropped into 3:4; VR showed the retired GameTheca fallback; trailer player lacked a recognizable CRT; rail mark was small; Help lacked visible bullets and readable hierarchy; rail footer added a box; dashboard mobile cards were too narrow | **In progress 2026-10-09:** refreshed populated desktop captures for Systems, Ways to Play, VR, Help and Dashboard. Dashboard now stacks widgets at widths through 760px while preserving saved desktop positions; populated staging confirms readable full-width cards at 375px and unchanged desktop layout. 2026-10-10 controlled-response VR checks show three cards using the shared fallback at 375/1440 without overflow or page errors; live household VR catalog review 2026-10-13 found 325 games and real cover art on the visible page at 1280px, with no horizontal overflow; no retired placeholder appears in the sampled results. Controlled fallback coverage remains the source of missing-art evidence because no missing/default-cover row appeared in this live page sample. Live Help review 2026-10-13 confirms the topic strip and reading panel hierarchy, nine visible bullets, and no horizontal overflow at 1280px or 375px. Ways to Play live review 2026-10-13 shows balanced 3/2/3 play-path, hub, and headset groups at 1280px; each collapses to one column at 375px with no overflow. Trailer route check 2026-10-13 shows the recognizable CRT shell around its player at 1280px; at 375px the iframe is 299x167px and the document remains within the viewport. Playback was not started. 2026-10-12 rail/footer acceptance: removed the classic Jinja footer divider while retaining account-row alignment/padding. Current-source CSS was synced only to the isolated oneirodex-ui-test-app theme volume; authenticated `/playromtest` captures at 1440x900 and 375x812 show the expanded rail/footer, 0px footer border, viewport-width document and no page errors. Focused `tests/test_templates_rail_chrome.py` passed 7/7; CSS-token lint reports 0 known/new violations. Captures: `.artifacts/run-oneirodex/p12-rail-footer-review-{1440,375}.png`. P16 `UX-07` / `JIN-05` adds the Account menu and shared Preferences dialog in the opt-in SPA shell; the Jinja fallback keeps its footer account row until the global zero-Jinja gate. All other listed UID-070 symptoms have evidence above. | UI corrections | done | +| UID-071 | Shared menus and admin page chrome | Dropdown menus looked unrelated and stayed open after clicking elsewhere; scan/filter actions were buried in page cards; filter page had nested frames; Themes page spacing differed from Libraries; Art Studio showed a nonfunctional generic skin label; Ops status text could be clipped by a short widget | **In progress 2026-10-09:** standardized menu surfaces and item controls, added outside-click/Escape dismissal for menu dropdowns, moved Scan Jobs actions beside the table while Unmatched/filter actions remain in the THN, removed redundant filter frames and the empty Art Studio label, and aligned Themes spacing to the shared admin page scale. The healthy status banner now defaults to one grid row; issue content still grows to fit, and only an untouched legacy default layout is compacted during load. Add-widget, game-detail admin and ContextBar disclosures now use native sequential focus instead of incomplete ARIA menu roles; Escape/selection and filter-panel Done restore focus to the trigger. **Current source rebuilt into isolated review staging 2026-10-09:** populated desktop capture confirms the banner is content-height and Readyz details are concise with a disclosure. Library filter keyboard and phone acceptance now passes at 375/1440 (Space toggle, panel bounds, Clear, Escape focus return). **Manual scan grouping closed for this slice 2026-10-12:** the dedicated Manual identify route retains library/location/layout and manual options, hides automatic scheduling, removal, image download and Start scan controls, and links to Automatic scan and Scan jobs. Focused admin tests (8/8), build and CSS/button lints pass; authenticated isolated review at 375/1440 confirms both Manual and Automatic routes, no horizontal overflow, page errors or writes. Captures: `.artifacts/run-oneirodex/p12-scan-manual-review/`. **Libraries summary focus completion 2026-10-12:** Escape, Done, and platform selection return focus to the top-bar trigger; outside click closes while the outside target keeps focus. Focused `LibrariesPanel.test.tsx` passed 12/12; admin pilot build and CSS-token lint pass. Authenticated isolated review at 375/1440 confirms a bounded 351px/345px popover, no document overflow, page errors, or write requests. Captures: `.artifacts/run-oneirodex/p12-library-trail-review/`. **Member tile action menu keyboard behavior 2026-10-13:** Escape closes and restores opener focus; outside click preserves the clicked link's focus; Tab from the disclosure reaches the first native menu action before Preview, and opening a second tile closes the first menu. `GameCard.test.tsx` and `buttonLanguage.test.js` passed 23/23 together, member build and CSS-token lint pass. A source-only desktop review confirms both shells are intentionally text-first and app/window/favicon art is brand identity; no desktop runtime launch was performed. No authenticated browser geometry review was run for this behavior-only change. Admin account disclosure source review: links and the palette button stay in native sequential tab order; Escape closes when focus is within the top bar and returns focus to the trigger; outside pointer dismissal closes without forcing focus away from the clicked target. This confirms source behavior only; no live admin runtime interaction was performed in this slice. Broader menu states and other admin chrome symptoms remain open. **P12 Add widget popup completion, 2026-10-13:** the popup now renders in the top-bar overlay layer to escape the mobile action strip clip; fixed positioning tracks trigger scroll/resize and clamps to the viewport. It preserves disclosure semantics, Tab-to-first-action, Escape focus return, and outside-click focus. Focused DashboardBoard tests 7/7, admin build, CSS-token lint and scoped diff check pass. Authenticated isolated dashboard review at 375x812 and 1440x900 confirms the popup is hit-testable above content, no document overflow/page errors, and Tab/Escape/outside-click behavior. Captures: `.artifacts/run-oneirodex/p12-dashboard-add-widget-final-{375,1440}.png`. Authenticated isolated AdminTopNav review also verified the account disclosure geometry at 375/1440, native Tab progression, Escape focus return and outside-click close with no overflow or page errors. Remaining native/hardware checks are explicitly deferred under UID-085/089. | UI corrections | done | +| UID-072 | Scan management, admin settings, platform art and themes | Unmatched comparison/action UI is hard to read; admin pages show outlines or unused background; scan jobs is a duplicate page; platform art, avatars, navigation icons, and built-in themes need a cohesive redesign | **In progress 2026-10-08:** Auto, Manual, and Jobs now share one compact Scan page; its THN switches and jobs actions have been removed, with job actions beside the table. Also redesigned comparison as a pop-out, simplified row actions, removed redundant page frames, widened legacy admin surfaces, replaced the seven shipped avatar SVGs, added selected-only theme-aware rail motion, and replaced shipped presets with six new designs. **Manual Scan correction + current capture 2026-10-09:** `.od-adminpage-panel` on the custom section triggered a more-specific legacy shell reset that removed its token surface; removing that conflicting class restores the shared card border/background/padding. Manual now uses the same `card-title`/header pattern as Auto Scan. Authenticated test-app captures at 1600×900 and 375×812 show matching 18.4px headings, panel surface, and no document overflow; `docs/media/screenshots/admin-scan.png` was refreshed. **System mark compatibility, 2026-10-12:** the canonical catalog expects 88 platform IDs across default + six current themes. Shipped artwork had default 72/88, retired `aurora`/`ember`/`violet` sets at 72 each, and `forest` at 58; canonical presets reported zero because assets remained in retired folders. Systems now tries current theme art, compatible retired art (`afterglow`→`aurora`/`ember`, `greenhouse`→`forest`, `tape-deck`→`violet`), then the per-system SVG glyph. Art Studio counts those compatible files and its missing-generation action skips them; it does not trigger generation. Focused mark tests 13/13, Systems tests 5/5, member build and CSS-token lint pass. Authenticated isolated review confirms the greenhouse tile loads the forest alias at 375/1440 with no overflow, page errors or writes; captures: `.artifacts/run-oneirodex/p12-system-mark-review/`. **Still open:** 16 marks missing in the 72-mark sets, 30 in forest, and no existing marks for monochrome/signal/deep-space; request a credit-budgeted Art follow-up to fill these only after a representative visual review. Remaining scan states and broader icon/theme parity also need review. See `docs/_private/evidence/ui-visual-review-2026-10-09.md`. **P12 handoff:** remaining platform/theme art coverage (16 missing marks in the 72-mark packs, 30 in forest, and no monochrome/signal/deep-space marks) stays on semantic SVG fallbacks. No paid generation was used. Defer art expansion until a representative visual review and explicit credit budget; route the review to the Art owner before asset generation. Existing scan, compatibility fallback and responsive evidence above remains valid; remaining scan-state coverage is deferred to P18 QA-UI-01. | UI corrections | deferred | +| UID-073 | Member/admin mobile shell | The rail covered the page on first load and the page view controls overlapped the navigation toggle at phone widths | **Done 2026-10-09:** shared rail visibility uses the explicit mobile `open` state; closed rails are `inert` and hidden from the accessibility tree, and open rails receive focus, trap keyboard focus, and restore focus on close. A visible Close action is inside the drawer. The toggle controls the named navigation landmark and its `aria-expanded` state follows mobile/desktop behavior. Member Library and admin Unmatched were checked in populated staging; pointer activation, Escape restore, and desktop collapse worked. | UI/UX review | done | +| UID-074 | Admin Scan / Unmatched at phone widths | Auto Scan selector and scan-mode choices competed for the same narrow grid; unmatched top actions expanded the shared top bar beyond the content reserve, while the wide unmatched table needs its own scroll area | **Done 2026-10-09:** Auto Scan stacks its configuration grid below 992px and scan modes wrap. Unmatched actions scroll in a bounded row below the navigation toggle through 900px; this prevents the destructive Clear All action from covering the toggle. Both wide scan tables are named, keyboard-focusable regions with visible focus treatment; ArrowRight scrolls their contents. Populated browser review at 320/375/390/700/768/900/1280px confirmed document width stays within the viewport. Scan Jobs and Unmatched captures refreshed. | UI/UX review | done | +| UID-075 | Admin section navigation on phones | Settings, Themes, Images and Art Studio top-bar destination buttons could make the page wider than the viewport, cutting off the page horizontally | **Done 2026-10-09:** At widths through 700px, admin section button groups stay within the top-bar page row and scroll horizontally as one group. Populated routes for Settings, Themes, Images, Emulators, Extensions, Art Studio and Integrations were checked at 320/375/390/700/768/900/1280px; document width matched the viewport on each. Theme, Image queue and Art Studio captures were refreshed. | UI/UX review | done | +| UID-076 | Mobile rail and scan-table accessibility | Closed mobile rail links remained keyboard/AT reachable; scan-table scroll regions had no accessible name or keyboard entry point | **Done 2026-10-09:** Member and admin drawers now expose a labelled modal navigation surface, disable the background while open, move and contain focus, close on Escape, and return focus to the toggle. Scan Jobs and Unmatched wrappers expose named regions with `tabindex="0"` and visible focus; populated staging confirmed keyboard ArrowRight scrolls both. Member/admin production builds and CSS-token lint passed; application test suites were not run. | UI/UX review | done | +| UID-077 | Preferences theme preview rollback | Closing Preferences without saving restored the accent and room but left the preview icon pack active | **Done 2026-10-09:** `preferences_modal.js` now saves the original icon-pack attribute and stylesheet URL on first preview and restores them on Cancel/Escape; Save keeps the chosen pack. Populated staging confirmed Afterglow preview (`pixel`) returns to saved Default (`outline`) and the original icon CSS URL. `GENERATOR_VERSION` 43 ensures installed theme-volume copies receive the fix after Reset Default Themes. | Theme review | done | +| UID-078 | Account dropdown keyboard semantics | Member and admin account dropdowns used ARIA `menu`/`menuitem` roles without the required menu keyboard model; Escape could remove a focused item without returning focus | **Done 2026-10-09:** the dropdowns now expose a labelled disclosure group with native Tab order; Escape closes and restores focus to Account menu. `aria-controls` is present only while the panel is rendered. Populated member/admin staging confirmed the expanded state, group semantics, keyboard entry, and focus return. | UI/UX review | done | +| UID-079 | Systems cards at phone widths | Long console names were clamped after two lines, hiding meaningful parts such as “System (NES)” | **Done 2026-10-09:** at widths through 760px, system names wrap without the two-line clamp. Populated staging at 375px confirms the complete Nintendo Entertainment System (NES) label is visible and the document remains viewport-width. | UI/UX review | done | +| UID-080 | Admin Ops readiness details | Readyz details serialized nested check objects into a dense table cell, making useful service state hard to scan | **Done 2026-10-09:** the Services table shows a concise readiness summary and keeps the complete payload behind a native disclosure. Authenticated test-app review confirmed Enter opens and Space closes the disclosure. At 375px, its JSON payload fits a 229px content box without horizontal overflow; the Services table is now horizontally scrollable at narrow widths with a visible hint, accessible region name and keyboard focus. At 768px it fits without horizontal overflow. Evidence and screenshots: `docs/_private/evidence/ops-ui-review-2026-10-09.md` and `.artifacts/run-oneirodex/admin-ops-readyz-disclosure-375-final-open.png`. | UI/UX review | done | +| UID-081 | Admin Libraries and Users table access | Narrow tables had no named keyboard entry point for horizontal scrolling, and repeated per-row actions shared generic accessible names | **Verified 2026-10-10:** both live tables expose captions and named focusable regions with visible focus styling. LibrariesPanel now names Scan/Edit/Delete/Group by library; the `/libraries` route serves the shared Python shell and React page, and the legacy Jinja host was removed; Users names Edit by account. Current admin production build on isolated authenticated staging at 375/1440 passes keyboard ArrowRight scrolling, row-name lookup, viewport-width documents and zero page errors. Libraries phone region: 325px client/734px content; desktop tables fit 1240px. Build and CSS-token lint pass. The prior source audit confirmed that the live LibrariesPanel uses the same accessible labels. Route migration preserves the same API-backed table behavior. | UI/UX review | done | +| UID-082 | Icon catalog and theme-pack parity | React rails, member top-nav, Jinja and theme packs have inconsistent semantic coverage and drawings | **2026-10-10:** Eleven canonical manifest glyphs generate React/Jinja adapters; declared aliases normalize in both renderers and are searchable without duplicate catalog specimens. The two live member top-bar adapters consume shared drawings; seven unused icon exports and the obsolete primary-nav map were removed after caller review. All 17 Outline keys render; gallery shows 61 canonical glyphs and 85 pack SVGs. At 320/375/1440px: no overflow or page errors; alias search and light preview pass. Focused template/shared/member/catalog tests: 21/12/6/4 passed; member/catalog builds, generator check and CSS lint pass. Captures: `.artifacts/run-oneirodex/ui-catalog-controls-{320,375,1440}.png`. Downloads destination label/glyph now match React; live authenticated playrom/image-editor routes at 375/1440 confirmed the label/glyph and no overflow or page errors. Captures: `.artifacts/run-oneirodex/jinja-downloads-{playrom,image-editor}-{375,1440}.png`. Fallback correction 2026-10-13: the five drawing packs scope generic CSS to their 17 supplied drawings plus the `downloads` alias, so other shared rail glyphs keep their standard Outline appearance. Source pack CSS and generated fallback CSS share the same selector; focused icon-pack tests pass 8/8. Catalog and Preferences docs describe the current 17-icon coverage. Authenticated populated member-shell preview across all five packs at 1440x900 and 375x812 passes: pack SVGs load, unsupported rail Activity keeps base Outline styling, no viewport overflow or browser console errors. Classic Jinja profile route also passes a Filled-pack check: supported user/library marks load masks; unsupported key/envelope/camera marks retain base 2px Outline styling. Temporary test-account preference was restored to Outline. Open: additional classic-route coverage. Desktop companion icon treatment was source-audited 2026-10-13: full/thin actions are text-labeled, and shared app/window/favicon artwork is separate brand identity. No desktop runtime launch was performed. **P12 handoff:** shared manifest/catalog and representative theme-pack fallbacks are implemented and verified above. Full classic-route/theme-pack parity remains open; carry it into P17 JIN-06/JIN-07 route migration and P18 QA-UI-01 evidence, where every remaining template route can be checked against the same rendered contract. This row does not claim complete Jinja glyph parity. | UI kit | deferred | +| UID-083 | Scan folder picker file-type marks | File-type mapping was unused, and folder navigation was pointer-only | **Verified 2026-10-10:** semantic theme-accent folder/file marks render in the authenticated scan page; folders are native buttons with 44px minimum height and visible keyboard focus. At 375/1440, intercepted folder/text-file listings verify both marks, Enter navigation and viewport-width document with no page errors. Listing fixtures establish UI behavior, not filesystem/API coverage. CSS-token lint passes. Captures `.artifacts/run-oneirodex/scan-folder-keyboard-{375,1440}.png`. | UI kit | done | +| UID-084 | Legacy Jinja shell accessibility | At mobile widths, Jinja navigation can remain keyboard reachable while offscreen; the page shell has no skip link or main landmark | **Verified 2026-10-10:** both live classic shell routes, playrom and image editor, tested at 375/1440 against current templates/JS and refreshed test-volume CSS. Each has one main landmark; Enter on the skip link focuses content. Mobile navigation starts inert/hidden, opens as a named modal with Close focused, wraps Tab/Shift+Tab across 19 visible stops, and Escape restores the toggle and inert state. No document overflow or JavaScript page errors. Temporary test reviewer/library/game removed after checks. Captures: `.artifacts/run-oneirodex/jinja-downloads-{playrom,image-editor}-{375,1440}.png`. Scope: these shell navigation/landmark defects; broader screen-reader and page-state review remains separate. | UI/UX review | done | +| UID-085 | Desktop companion accessibility | Native window keyboard, announcement and screen-reader behavior still need confirmation | **Source + focused regression, 2026-10-09:** version chooser now names its radiogroup, contains Tab/Shift+Tab, and restores focus to the launcher on close. Full/thin connection and trusted-share status regions use polite atomic announcements. Canonical primary/secondary colors clear 4.5:1; calculated primary 10.22:1, secondary 14.44:1, Play 6.53:1 at rest / 5.48:1 hover. `npm --workspace oneirodex-desktop exec -- vitest run src/download-picker.test.ts` (6 passed), desktop build, and CSS-token lint pass. **Still open:** native-window keyboard/focus and screen-reader announcements were not exercised; see initial source audit in `docs/_private/evidence/ui-accessibility-audit-2026-10-09.md`. **P12 handoff:** the source and focused regression checks are complete, but native-window keyboard and screen-reader behavior requires a native Windows run with assistive technology. Defer to P18 QA-UI-01; no native or spoken-output acceptance is claimed here. | UI/UX review | deferred | +| UID-086 | Standalone Ops Glance accessibility | Polling status updates are not announced and the standalone app has no level-one heading or reduced-motion override for hover transitions | **Source correction 2026-10-09:** title is now an `h1`; a polite status region announces the initial severity and severity changes, while unchanged polls and timestamp changes leave its text untouched. Standalone CSS minimizes transitions under `prefers-reduced-motion`; the mirrored default-theme stylesheet has the same selectors. Focused `OpsApp.test.tsx` 6/6, Ops Glance production build, and CSS-token lint pass. The standalone `index.html` now links the canonical dark default theme foundation and applies a responsive page inset; themed panel layout and responsive behavior were confirmed with a sanitized intercepted summary at 1440×900 and 375×812, with no overflow or page errors. On the standalone fixture, Tab reaches Refresh first and then the five rendered links in order; Enter on Refresh requests a new summary, and visible keyboard focus is present. The static entry cannot resolve a signed-in user’s selected theme; screen-reader announcement playback and live reduced-motion setting checks remain open. See `docs/_private/evidence/ui-accessibility-audit-2026-10-09.md`. **P12 handoff:** browser fixture and source evidence are complete, but live screen-reader announcement playback and OS reduced-motion preference verification remain. Defer those device checks to P18 QA-UI-01; no live assistive-technology acceptance is claimed here. | UI/UX review | deferred | +| UID-087 | Admin Ops service status truth and scanability | Overall banner can say “All systems healthy” while an enabled LiveKit service is visibly misconfigured; service-table states need semantic styling | **UI + Backend contract verified 2026-10-09:** the Services table uses shared-token semantic badges with text labels. Backend passes LiveKit status into `derive_issues()`; explicitly enabled + `configured: false` and explicitly configured + `reachable: false` produce warnings, while disabled or unknown states do not invent an issue. Focused contract tests: 27 across the companion UID-088 suite. Authenticated oneirodextest capture confirmed the warning banner and matching LiveKit row; 375px and 768px table behavior and keyboard scroll were also checked. Evidence: `docs/_private/evidence/ops-ui-review-2026-10-09.md`. | +| UID-088 | Admin Ops service-table unavailable states | Partial Malware data must not read as “off” or a confirmed ClamAV outage | **UI + Backend contract partial, 2026-10-09:** missing `enabled`, `block_on_hit`, or ClamAV reachability remains null/unknown; explicit false stays false, and `reachable` falls back to `available` only when present. The UI renders unknown neutrally and keeps explicit states labeled. Focused contract tests: 27 across the companion UID-087 suite. Authenticated oneirodextest capture exercised enabled with an explicit failed ClamAV probe. **Sanitized browser-response fixtures captured at 375px 2026-10-09:** missing malware object and explicit-null fields both render neutral `unavailable` (`na`); explicit disabled renders neutral `off` (`na`); enabled + reachable ClamAV renders `on · ClamAV up` (`good`). Body/document widths stayed 375px and no browser errors occurred for all four states. These intercepts verify presentation only, not endpoint-backed payloads. **Still open:** authenticated endpoint fixture coverage for the unknown, disabled, and reachable payload variants; do not infer live service health from these synthetic states. See `docs/_private/evidence/ops-ui-review-2026-10-09.md`. **P12 handoff:** backend contract and sanitized presentation states are verified above. Endpoint-backed unknown/disabled/reachable ClamAV payload captures remain open for P18 QA-UI-01; sanitized interception is not endpoint evidence. | UI/UX review | deferred | +| UID-089 | Desktop companion and thin-client visual parity | Desktop companion surfaces need consistent type, spacing, controls, and responsive/native sizing across the full and Thin clients | **Source + browser correction, 2026-10-10:** desktop CSS imports canonical `od-tokens.css` and uses semantic surfaces/focus with green primary and neutral secondary controls; Thin shares the full companion’s `.shell` inset and `.muted` intro, and its action row wraps. The earlier no-token/flat-button finding is superseded. A separate headless browser review of Thin at 320/375/610/1280px confirmed viewport-width document/body, 200 response, four visible actions, and no page errors; screenshots are in `.artifacts/run-oneirodex/thin-layout-{320,375,610,1280}.png`. This is a browser preview, not native-window acceptance. Native Windows sizing/scaling, minimum fit, multi-monitor, and screen-reader checks remain open. See `docs/_private/evidence/desktop-ui-review-2026-10-09.md`. UID-085 separately tracks focus, contrast, and live feedback. **P12 handoff:** token and responsive browser preview are verified above. Native Windows scaling, minimum window fit, multi-monitor behavior and screen-reader review require the companion runtime; defer to P18 QA-UI-01. | UI kit + Desktop | deferred | +| UID-091 | Shared filter primitives | Filter controls are implemented independently across Library FilterBar, Trailer FilterPanel, and app-level chip controls; consumers cannot preview or reuse a common filter contract from `@oneirodex/ui` | **First shared primitive, 2026-10-10:** `@oneirodex/ui` exports controlled multi-select `FilterChipGroup` with a named group, `aria-pressed` state, 44px default targets, compact sizing, optional applied-result announcement, clear action, responsive wrapping, and visible focus. The live Library FilterBar now uses it for badge and kind chips, and the store selector uses it for multi-select ownership stores; standalone member chip adapters and the local UI catalog use it too. Verification: shared component tests 2/2; member BadgeFilterChips, ItemKindFilterChips, OwnershipFilterControls, and FilterBar tests 23/23; catalog tests 3/3; shared/member typechecks and member/catalog builds pass; preset-theme tests 32/32; theme-token contrast tests 30/30; CSS-token lint passes. Browser review on a separate local catalog server at 1440px and 375px confirmed dark initial palette, 44px targets, no document overflow, click/Space selection, applied counts and Clear behavior; focus ring was 2px. Captures: `.artifacts/run-oneirodex/ui-catalog-filter-1440-applied.png`, `ui-catalog-filter-375-section-keyboard.png`. **Authenticated member follow-up 2026-10-10:** at 375/1440, Space toggles New, the panel stays within the viewport, Clear removes selection and Escape returns focus to Filters. Captures: `.artifacts/run-oneirodex/member-filters-{375,1440}.png`; temporary local account/library/game removed. **Trailer consumer follow-up, 2026-10-13:** genre/theme multi-select now uses the shared `FilterChipGroup` inside the existing draft form; Apply still makes the GET query, Clear removes the draft, and selected options remain pressed after Apply/reopen. `TrailersPage.test.tsx` 14/14, member typecheck/build and CSS-token lint pass. Authenticated isolated review at 375/1440 confirmed 44px options, keyboard Space selection, Apply/Clear, exact `genres=3` query, zero overflow/page errors, and no write requests. Review DB had two libraries but no genre/theme options, so only those two labels were mocked for presentation; screenshots: `.artifacts/run-oneirodex/p12-trailer-filter-review/trailers-{375,1440}.png`. **P17 route follow-up:** `/admin/edit_filters` now serves the direct React manager; guarded list/add/delete API contracts remain, and the former GET delete link no longer mutates state. Other workflow-specific filters and classic renderer parity remain open. **Still open:** more compatible consumers, a server-rendered adapter decision, and Library/Trailer draft semantics beyond this preserved owner-managed Apply contract. **P12 handoff:** the shared controlled chip primitive is live in Library, Trailer and the catalog; consumer query/apply behavior is preserved. Defer other workflow-specific filters and the classic-renderer adapter decision to P17 JIN-06/JIN-07 and P18 QA-UI-01. Keep each page owner’s existing draft/immediate-apply semantics; this does not claim all app filters have migrated. | UI kit | deferred | +| UID-094 | Shared table touch sizing | Search and column filters were about 27px tall; sort controls could be only 16px tall | **Verified 2026-10-10:** shared DataTable applies 44px minimum search/filter/sort targets through 760px and for coarse pointers. Catalog browser checks at 320/375/1440 confirm target geometry, Enter sorting, text filtering, viewport-width document and zero page errors. CSS-token lint passes. Catalog evidence covers the shared specimen; populated admin route checks remain under UID-081. | UI kit | done | +| UID-093 | Classic image-editor cover actions | Uploads lose the cover ID and delete control; deletion can retain stale controls and restore the retired bitmap | **Verified 2026-10-10:** preview and one Delete Cover Image control now track the latest uploaded ID inside the existing cover container; cancelled deletion preserves it, confirmed deletion restores the shared title/brand fallback. Upload feedback uses theme tokens and role=status. Authenticated 375/1440 browser checks used intercepted upload/delete responses, with no page errors; real file persistence and provider apply remain unverified. Chrome parity 30/30, JS syntax and CSS-token lint pass. | UI kit | done | +| UID-092 | Classic image-editor no-cover fallback | The image editor shows the retired product name in its missing-cover bitmap | **Verified 2026-10-10:** missing-cover markup reuses shared fallback/brand classes with live title and No cover art; upload renderer removes the fallback and retains the preview ID. Authenticated fixture-game captures at 375/1440 show no retired bitmap or overflow; browser renderer transition passes. Chrome parity 30/30, JS syntax and CSS-token lint pass. Capture: `docs/media/screenshots/admin-image-editor.png`. Provider search/apply and file-upload API flows remain outside this visual defect’s scope. | UI kit | done | +| UID-090 | Admin Integrations navigation | Meta / Quest and Indexers section links can land on the Artwork or Email tab instead of their named destinations | **Done 2026-10-10:** deep links now activate the Artwork & secondary tab, retain `#meta_quest` / `#indexers`, and scroll the target heading into view after the tab layout settles. Authenticated test-app review used current source in the disposable `oneirodex-ui-test-app` theme volume: both destinations were visible at 1440×900 and 375×812, the fragment stayed intact, document width matched the viewport, and no browser errors occurred. Evidence and captures: `docs/_private/evidence/ui-navigation-destination-matrix-2026-10-09.md` and `.artifacts/run-oneirodex/admin-integrations-{meta_quest,indexers}-{375,1440}-actual-test-volume.png`. | Admin navigation | done | | BUGSCRUB-DESKTOP | Desktop download / Play | Cancel started a base download; failed remote playtime registration reported a local launch failure | Cancellation now remains distinct from base selection; local launch success and process watching survive remote tracking failure. Focused picker/launch/watcher regressions cover both. | Bug scrub | done | | BUGSCRUB-20261005 | First-run and page refresh | Library creation was hard to reach; setup ended before a first library; several admin/member pages had old layouts or incomplete states | The first-run wizard offers optional app-managed API credentials and a validated first-library create + scan step. This follow-up restores the authenticated server-root folder picker with a keyboard-trapped modal; centers the no-library scan overlay; moves image-queue, invite, and VR filters into the THN; centers unmatched actions; makes Library & Matching a unified row view; tightens installed-theme cards and whitelist form; removes stale SMTP / IGDB migration notices; sends admin Help to the member Help page; and adds an animated loading indicator. The populated isolated staging capture refreshed screenshots and the how-to clips; focused admin, VR, loading, browse-path, and security-route tests pass. Legacy-page retirement remains open pending route-by-route feature parity. | Bug scrub | in_progress | | UID-066 | Controller / D-pad navigation | No shared way to move between controls with a D-pad outside Big Picture (its gamepad loop is inlined) | Only `useShelfGridNavigation` existed, for cover grids. **LIB-02 2026-09-29:** `hooks/useListFocusNavigation` (Arrow/Home/End over `[data-od-nav]`, text fields untouched; radios marked `data-od-nav` are stops and Enter picks one) now drives the store list, match review and first-run, and `hooks/useFocusRecovery` returns focus to the card or row when the control it was on disappears (panel toggle after Confirm / Save link / Import, Stop ↔ Sync, the form after a failed save), never taking back focus the member moved; Steam Deck's browser template maps the D-pad to arrows. Other forms remain Tab-only, and nothing has run on a real controller or Deck yet (DECK-01). | P06 | open | | UID-067 | Native install terminal | Installer and server startup output lacked a shared character or visual through-line | **In progress 2026-10-08:** Rebuilt Onei as a shaded character-ramp portrait of an armored abyssal crab kaiju rising over the city. Native, standalone, and Docker startup narrate all five real initialization phases and close with a gate-opening or guarded-recovery scene before web-server logs. Character design is pending human review. | Native install | in_progress | -| UID-065 | Account modal focus | `AccountModal` (profile, password, tokens panels) has no initial focus, no Tab trap and no focus return; it does not use the shared `` | Found during LIB-02 (2026-09-28); store setup went to routed pages instead, so it was not touched. Adopt `` (`initialFocusRef`, Tab cycling, focus return) or add the three behaviours. | — | open | +| UID-065 | Account modal focus | `AccountModal` (profile, password, tokens panels) lacked initial focus, a Tab trap, and focus return | **Done 2026-10-09:** AccountModal now uses the shared modal shell. Member and admin populated staging checks confirm initial focus on Close, Tab/Shift+Tab containment, and Escape/Close focus return to the persistent Account menu button. | UI/UX review | done | | UID-064 | Admin pages / phone width | **`.od-seg` could not wrap — fixed.** Measured emulation-independently on Admin → Integrations at a 375px layout viewport: a six-item segmented strip was **457px wide inside a 315px parent** — wider than its own container, which is an overflow no matter what the viewport is doing. `.od-seg` was `inline-flex` with no `flex-wrap`. Now wraps (generator 39); re-measured 309px in a 309px parent, and at 1280px it is still one 457px row, so wide layouts are unchanged. **Still open, and probably not a bug:** `/admin/edit_filters`, `/admin/detail_layout`, `/admin/emulator_profiles` and `/admin/reference_sets` reported `#admin-legacy-content` at ~639-649px inside a correctly-sized 375/390px grid track — but no CSS rule sets a width on it, no descendant is that wide, and its computed width tracks the *pane's real width* while `body`, `html` and the grid track all report the emulated width correctly. That is the signature of a viewport-emulation artifact. Do not 'fix' it from those numbers: reproduce on a real narrow window first, and prefer the child-wider-than-parent test, which assumes nothing about the viewport. | walk of 32 admin + 21 member routes, 2026-09-18; `.od-seg` fix verified in-browser | partly done | | UID-001 | Library badges | Badges “all over the place” | Cap=2 + corner collision + platform chip + pinned VR/MISSING — four-corner map · occupied corners only · OUT/~ /RELEASE retirement · rounded-square chrome (2026-08-01) · **QA PASS 31/31** · DoD met · live visual skipped | W22 | done | | UID-002 | Library filters | Filter panel does not slide left; tiles don’t reclaim space | Prior work toggled visibility/opacity instead of width collapse + grid reflow | W22 | done | @@ -73,7 +144,7 @@ Status: `open` | `in_progress` | `done` | `deferred` | `wontfix` | UID-047 | Game details | Human 2026-09-05: buttons should be one row per section and reflow; screenshot strip ragged in small mode; path box squeezed to six lines while its button column sat empty | One `flex-wrap` action row let break points drift with content, so which buttons ended up together changed per title. Thumb strip mixed fixed-width images with text ("Trailer 1") in a flex row. **Done 2026-09-05:** three `__quick-row` groups (play · elsewhere · check), thumbs on an `auto-fill` grid of equal cells, Open path moved onto the Details heading so the path spans the panel, and is an `od-btn--sm od-btn--pill` so it reads as a heading action rather than a second box on the rule. | — | done | | UID-008b | Loaders (reduced motion) | Human 2026-09-05: still seeing loading screens not animating | UID-008/008a fixed *scope*; this is the opposite failure. `@media (prefers-reduced-motion: reduce)` set `animation: none !important` on every motif descendant, so with the OS setting on the indicator was a static glyph — indistinguishable from a hung page. **Done 2026-09-05:** large-amplitude motion still stops; the motif itself gets a slow opacity breathe (no motion vector). Both copies — SPA `LoadingMotif.css` and theme `od-loading-motifs.css`, the latter also dressing the Jinja pages. Picker specimens excluded. | — | done | | UID-048 | Discover as a zone | Human 2026-09-05: "still piss poor and just useless as a zone users will use — why didn't we update it with the research"; also a pager offering 139 pages for 50 items | Twelve hard-coded rows, several of which are empty on a household install; `/api/discover/rows/*` returned `has_more: true` with no `total`, so "Load more" could never say how much is left. Supersedes the chrome-only scope of UID-020. **Empty rows done 2026-09-05:** the "honest empty" hide was a four-identifier allow-list (`STOREFRONT_SHELF_IDS`), so `continue_playing` with no progress and `friends_playing` with no friends still rendered as headings over nothing — and `discover_feed`'s floor rule could not catch them, because it only fires when *dedupe* did the thinning (`len(kept) < floor <= len(candidates)`), which a row that selected zero never enters. Now `RowSpec.hide_when_empty`, default True for system rows and False for admin-curated zones, applied inside `assemble()` so a dropped row frees its slot. **Totals done 2026-09-05:** a selector has no count sibling, so the row endpoint over-fetches to a `TOTAL_PROBE` ceiling and reports an exact `total` when the source is exhausted inside it, else `total: null` + `total_is_estimate: true`; the control reads "Load more (N left)". **Pager was already fixed** — `showPager = layout !== 'grid'` in LibraryApp.jsx. **Zones done 2026-09-05:** `oneirodex/utils/discover_zones.py` declares five surfaces (For you · New & updated · Popular here · Elsewhere · Curated) as a *filter over the feed* — a zone owns no rows of its own, it names identifiers `resolve_feed` already produced, so ACL, section visibility, schedule windows, member hide, dedupe and `hide_when_empty` all run unchanged. `_assemble_sections` took one optional `zone_slug` argument rather than growing a second implementation. Consequences that fall out for free: hiding a shelf removes it from its zone without the zone knowing, and a zone whose rows are all empty resolves to nothing and is dropped from the strip — the honesty rule that removed empty rows removes empty zones too. Unlisted rows are placed by `RowSpec.family`, so adding a row needs no zone edit, and generated `because_you_played:` rows follow their prefix. `GET /api/discover/zones` + `/api/discover/zones/`; `DiscoverZonePage` at `/discover/zone/:slug`; `DiscoverZoneStrip` above the feed, which fails silently because it is a navigation aid over a page that already works. **Naming debt logged as UID-059.** | — | done | -| UID-043 | First run | Scan finished with zero matches falls back to the generic empty state, so the operator gets no hint | `EmptyState` received only `libraryCount` / `gamesCount`, so it could not distinguish “no scan has run” from “a scan ran and matched nothing” — the shipped string was written to be *true* in both cases (“Nothing’s turned up in a scan yet”) rather than useful in either, which left an operator with a misconfigured scan path no hint that anything was wrong. **Done 2026-09-06:** `get_first_run_state()` (`utils/functions.py`) returns `scan_has_run` + `unmatched_count`; `routes_library` passes them, `member_spa.html` carries them as `data-scan-has-run` / `data-unmatched-count`, and `EmptyState` branches three ways. `scan_has_run` keys off **terminal status**, not `last_run` — a job is stamped the moment it is picked up, so `last_run` would announce a finished scan while one was still running. A failed scan counts as having run: first-run copy after a failure is the same lie in a new place. `unmatched_count` excludes Ignore/Duplicate (already dealt with), and only an **admin** is pointed at Unmatched — a member cannot open that screen. Voice follows UID-041: playful while nothing is wrong, plain once something failed. Verified end-to-end on the running app (dataset → React), since the chain crosses five hand-offs. | — | done | +| UID-043 | First run | Scan finished with zero matches falls back to the generic empty state, so the operator gets no hint | `EmptyState` received only `libraryCount` / `gamesCount`, so it could not distinguish “no scan has run” from “a scan ran and matched nothing” — the shipped string was written to be *true* in both cases (“Nothing’s turned up in a scan yet”) rather than useful in either, which left an operator with a misconfigured scan path no hint what was wrong. **Done 2026-09-06:** `get_first_run_state()` returns `scan_has_run` + `unmatched_count`; `routes_library` passes them and the Python-built member shell publishes them as `data-scan-has-run` / `data-unmatched-count`, so `EmptyState` branches three ways. `scan_has_run` keys off terminal status, not `last_run`; a job is stamped when picked up, so `last_run` would announce a finished scan while one was still running. A failed scan counts as having run. `unmatched_count` excludes Ignore/Duplicate, and only an admin is pointed at Unmatched. The behavior was verified end-to-end on the running app; the shell now carries the same values without a Jinja template. | — | done | | UID-053 | Tile select control | Human 2026-09-05: clicking the title-select button on and then off leaves it visible and highlighted; the Clear button clears it correctly | `LibrarySelectionBar.css` lit the control on `:focus-within`, and clicking a checkbox to *deselect* leaves focus on it — so the container stayed `:focus-within` and the control stayed at `opacity: 1` after the tile was no longer selected. Clear never showed the fault because it moves focus to the selection bar, which drops `:focus-within` on the tile; that asymmetry was the tell. **Done 2026-09-05:** the arm is `:has(.od-tile-select:focus-visible)` — false for a mouse click, true for keyboard focus, so the control still cannot be tabbed to invisibly. Deleting the arm outright would have fixed the symptom by hiding the control from keyboard users. | — | done | | UID-054 | Bar button language | Human 2026-09-05: break the one-bar in the top header nav, give each one 2px space | `.od-seg` was a single outlined pill — one border round the strip, `overflow: hidden` clipping the members into it, and `.od-seg__item + .od-seg__item` welding them with a hairline. `.od-cbtn-group` did the same by a different mechanism (`margin-inline-start: -1px` plus first/last-child corner rounding), which UID-044 had then taught to *look* like a seg. So a strip of four views read as one wide control rather than four choices. **Done 2026-09-05:** both lose the outer border, the clip and the fusing; each member carries its own border and radius, `gap: 2px`. The seg focus ring returns to `outline-offset: 2px` now that nothing clips it, and the 2px-seam repair added for UID-044 is deleted rather than overridden. `GENERATOR_VERSION` **29** — Reset Themes required. | — | done | | UID-055 | Button box is font-blind | Human 2026-09-05: tighten all box buttons in every UI/page, still not clipping the text, and reactive to the font being used | `.od-btn` was `min-height: var(--od-control-h)` with `rem` padding — a fixed box the text sat inside, so raising the type (a larger theme font, text-only zoom, a `--od-font-md` button) moved the text and not the space around it: roomy → cramped → clipped with nothing in the CSS changing. **Done 2026-09-05:** `.od-btn`, `.od-cbtn` and `.od-seg__item` share one box — `em` padding (`0.32em`/`0.7em`, tighter than the old `--od-space-4`) over `min-height: max(--od-btn-h-min, 1lh + 2×pad + 2px)`, so the text and the space around it are the same measurement and clipping is structurally impossible. `--od-btn-h-base` is a new density floor, deliberately lower than `--od-control-h` (which still has to keep inputs tall enough to type in) and never below the 24px WCAG 2.5.8 target minimum. **Closes the UID-050 trap:** `od-density.css` no longer re-asserts `.od-btn` metrics at all — it sets a token, which is inherited rather than cascaded against, so the `:not(.od-btn--sm)` guard is gone and the next modifier cannot lose the same specificity tie. Bootstrap's `.btn` keeps the old mechanism because it does not read our tokens. | — | done | @@ -81,12 +152,12 @@ Status: `open` | `in_progress` | `done` | `deferred` | `wontfix` | UID-057 | Shelf stack keyboard nav | Grid (and Discover) are a vertical stack of horizontally scrolling shelves, and focus dead-ends in them — the most-cited failure of 10-foot library UIs | Every tile was its own tab stop, so reaching the third shelf meant tabbing through every tile on the first two; and nothing defined what Down means when the shelf below is shorter than the column you are in, so focus either vanished or stopped moving while the page visibly continued. **Done 2026-09-05:** `useShelfGridNavigation` — one roving-tabindex stop for the whole stack, Left/Right along a shelf, Up/Down between them, Home/End per shelf and Ctrl+Home/End across the stack, `scrollIntoView({block:'nearest',inline:'nearest'})` on every move so focus never leaves the viewport, and reduced-motion honoured. Arrows never change selection; Enter and Space are left to the cell. The remembered column survives a clamp into a short shelf, which is what stops a ragged stack from dragging focus to the left edge — a `focusin` sync was overwriting it and is now guarded during programmatic moves. A `MutationObserver` re-rovers as shelves fetch their own covers. | — | done | | UID-058 | Grid vs Tile naming | Members read "Grid" as "denser Tile" and get genre shelves; the FAQ exists to explain the difference | `docs/research/study-member-discovery.md` assumption **B2** records this, and task T6 tests it directly: *"Switch to Grid. What did you expect that to do?"* An FAQ entry that has to say "Grid is genre shelves (not denser Tile)" is the product admitting the label does not carry. **Not started, and deliberately not renamed here** — which name is right is a research question, not a code one, and study 2 is drafted but unfielded. Logged so it stops being invisible. **Decided 2026-09-06 (human): field study 2 first**, rather than rename by decree — so this row is blocked on running T6, not on someone picking a better word. Recorded at the head of `docs/research/study-member-discovery.md` so the study carries its consequence. | — | open | | UID-059 | Two things called a "zone" | `oneirodex/utils/discovery_zones.py` and `oneirodex/utils/discover_zones.py` — one letter apart, unrelated concepts | The older module took the word first: there, a "custom zone" is **one admin-authored shelf** (manual pick list or filter), and the admin UI says "Custom zone" in `describe_zone_config`. In the new module a zone is a **page of shelves**, and a custom shelf is one of the things a zone can contain (they land in `curated`). Both names are defensible alone and confusing together. **Done 2026-09-06 on a human "rename the old admin concept":** `discovery_zones.py` → `discovery_shelves.py`, `validate_zone_config` / `_filter_zone_query` / `resolve_custom_zone_games` / `count_custom_zone_games` / `describe_zone_config` all become `*_shelf_*`, and the admin copy says **shelf** — page lede, Add shelf, Shelf name, modal titles, row descriptions, toasts, API error strings. `section_type='custom'` is untouched: the rename is names, not stored data. The DOM ids and CSS classes in `admin_discovery_sections.html` **keep** `zone` on purpose, with a comment saying why — `discovery_sections.js` is theme JS served from the library volume, so it only changes on Reset Themes, and renaming ids would break every button on that page for the window between a deploy and that reset. `GENERATOR_VERSION` **30** — Reset Themes required for the renamed strings. | — | done | -| UID-060 | Classic destructive confirm | The Discovery shelves admin page still deletes a shelf through `confirm()` in theme JS | UID-042 ported the **SPA** sites to `confirmAction()`. `oneirodex/setup/default_theme/js/admin/discovery_sections.js` is a Jinja page's theme JS: it cannot import from `frontend/shared/`, and it ships from the library volume rather than the image, so a port is a different job with a Reset Themes boundary in it. **Done 2026-09-12.** Classic-theme copy of the house dialog (`odThemeConfirm`) + `.od-confirm` CSS in the page stylesheet. `GENERATOR_VERSION` **31** — Reset Themes required. | — | done | +| UID-060 | Classic destructive confirm | The retained legacy Discovery shelves theme JS contains its confirmed-delete adapter; `/admin/discovery_sections` now uses the React `confirmAction()` flow and the old page body is removed | UID-042 ported the **SPA** sites to `confirmAction()`. `oneirodex/setup/default_theme/js/admin/discovery_sections.js` is a Jinja page's theme JS: it cannot import from `frontend/shared/`, and it ships from the library volume rather than the image, so a port is a different job with a Reset Themes boundary in it. **Done 2026-09-12.** Classic-theme copy of the house dialog (`odThemeConfirm`) + `.od-confirm` CSS in the page stylesheet. `GENERATOR_VERSION` **31** — Reset Themes required. | — | done | | UID-061 | Thin seat saw companion buttons | The member SPA offered Download / Install / Update / Uninstall inside the thin client's library window, where none of them can complete | The SPA had no way to know: the thin shell opens a plain webview with an ordinary cookie session, which is identical on the wire to a browser tab, and `/api/client/capabilities` cannot help because a browser session with no API token reports every lifecycle capability denied — wiring that naively would have stripped Install from the *web* UI too. **Done 2026-09-06 (TC-3):** the shell states its own seat by opening `/?seat=thin`; `utils/seatMode.js` latches it into `sessionStorage` (session, not local — a normal tab opened later must not inherit it) and `GameActionBar` renders one honest line instead of four dead buttons. Moonlight stays: streaming is not a local install. Presentation only — enforcement is still the thin token's missing `write:download` plus the thin build's absent FS ACL. | — | done | | UID-062 | Admin SPA freezes with password managers | Human 2026-09-08: admin pages are all over the place and freeze a lot with the Bitwarden extension, which shouldn't do anything. The member SPA does neither. | Password-manager extensions walk the DOM for fields and reposition an inline overlay on every mutation. Admin-specific aggravators the member SPA lacks: (1) `DashboardBoard.jsx` measured (`getBoundingClientRect` + `scrollHeight`) and wrote `setLayout` synchronously inside its own `ResizeObserver` callback — a read→write→re-observe loop in one frame — and the effect re-ran every render via `[widgets, layout.length]` deps it wrote itself, driven by the 15s ops poll; (2) `DashboardPage`'s `widgets` was a fresh object literal per render, rebuilding ~14 elements on every `PageStatus` ellipsis tick; (3) `type=email`/`type=password` carried only `autoComplete`, and no `data-*` PM opt-out existed anywhere in the repo; (4) the whole routed tree was one ``, so every same-kind navigation remounted the page subtree (full DOM teardown → extension re-scan); (5) the 4s `ScansPage` poll folded `processed`/`elapsed`/`eta` into its diff key, re-rendering unvirtualised job tables + `DupeGlance`'s `` every tick. **Done 2026-09-08 (branch `fix/admin-ui-align-and-freeze`):** rAF-coalesced board effect with deps `[visibleKey, minsFn]`; `useMemo` the `widgets` object (and tightened OpsPage's memo deps to what `snapshot` doesn't subsume); `formIgnore.js` exporting `PM_IGNORE` (`data-1p-ignore`/`data-bwignore`/`data-lpignore`) spread on CreateUserForm, UserEditor, DataTable filters and DupeGlance, plus `data-form-type="other"` on the two forms; `renderAdminKind(kind)` hoisted and consumed through `useMemo(…, [kind])` (per-kind ``s rejected — the hand-maintained matcher makes them a blank-page risk); ScansPage progress bucketed to 5% before the diff key and dropped from the key while idle, `DupeGlance` wrapped in `memo()`. `PageStatus.jsx`'s 420ms loading-portal interval was left — member's `PageStatus` runs two intervals at 150/400ms and does not freeze. SPA-side sibling of UID-030 (the Jinja scan freeze), not a regression of it. | — | done | -| UID-063 | Admin Help off-token & off-language | Human 2026-09-08: help in the admin app is much worse than help in the member app; they should align. | The live admin Help page is server-rendered (`admin_help.html` + `admin/admin_help.css`) and ran on a pre-token vocabulary that no longer exists — `--spacing-rem-xl`, `--text-white`, `--font-size-3xl`, `--border-light`, `--primary-brand-30`, `--table-bg-40-alt` — plus raw `15px` padding, `transition: transform 0.3s ease`, a `►` pseudo-chevron and a jQuery accordion mutating `style.display` on open. The token ratchet never caught it because it only inspects hex / `border-radius` / `font-size` literals. A dead React `HelpPage` stub (an unstyled `
      `) sat in `pages.jsx` and never mounted on the route. **Done 2026-09-08:** `admin/admin_help.css` rebuilt as `.od-adminhelp__*`, ported class-for-class from member `HelpPage.css` — hero (icon medallion + radial-gradient glass, `var(--od-font-2xl)` display title as the sanctioned UIR-3 title-retirement exception), bordered-glass section panels with a 3px `data-tone` edge (accent/info/success/warning/danger) matching `.od-admin-panel`, `:focus-visible` rings, `@media (prefers-reduced-motion)`. Markup is native `
      `/`` — no open/close script, keyboard-accessible for free, no reflow-on-open. `od_admin_help.js` trimmed to top-bar quick-nav + open-on-deep-link. Dead React `HelpPage` export + `case 'help'` + import removed, so `help` is no longer a resolvable SPA kind. No content change. `admin/admin_help.css` is a theme-volume asset — an operator on an existing deploy sees the restyle only after a redeploy + Reset Themes; `od_admin_help.js` ships with the image (static/js, no Reset Themes). | — | done | +| UID-063 | Admin Help off-token & off-language | Human 2026-09-08: help in the admin app is much worse than help in the member app; they should align. | The former Jinja admin Help screen had old theme tokens and a scripted accordion. **Done 2026-09-08:** the markup and styles were aligned with member Help, with native `
      /`, visible focus, and reduced-motion support. **Route retirement, 2026-10-13:** `/admin/help` redirects to shared `/help`; the obsolete admin template and page-only script/theme source were removed after route and reference checks. The shared member Help is now the canonical destination. | — | done | --- diff --git a/docs/dev/ui-style-guide.md b/docs/dev/ui-style-guide.md new file mode 100644 index 000000000..f83dff924 --- /dev/null +++ b/docs/dev/ui-style-guide.md @@ -0,0 +1,340 @@ +# Oneirodex UI style guide + +> **Doc status:** Active + +This is the shared visual and interaction contract for Oneirodex web surfaces. +It guides the member and admin SPAs, Ops Glance, Python-built document shells, +and future consumers. It is a baseline for review, not proof that every page +already follows it. Unresolved page findings remain in the +[UI debt log](ui-debt-log.md). + +## Product defaults + +- Start in the dark green aurora theme, independent of the operating-system + preference. Keep text, controls, focus and status readable. Where a surface + offers light themes, support them through the same semantic token contract. +- Use the existing `--od-*` token vocabulary and `.od-*` primitives. Literal + colors, spacing, type, shape, motion and elevation values belong in the + canonical token/theme CSS, not in component styles. Run + `node scripts/css-token-lint.mjs` after CSS or JSX style edits. +- Preserve theme layers: color palette controls semantic colors; room/era + controls scenery and may select an era glyph hand; icon pack controls glyph + appearance. Status colors and external brand marks retain their meaning. +- Buttons and fields must fit their labels at user zoom and larger text sizes. + Buttons grow with content, retain the shared target-size floor, and every + rendered React ` + + + + ) : null} +

    + ) +} diff --git a/frontend/admin-app/src/components/ArtworkPicker.tsx b/frontend/admin-app/src/components/ArtworkPicker.tsx index 27f0223c5..c3ccdb00c 100644 --- a/frontend/admin-app/src/components/ArtworkPicker.tsx +++ b/frontend/admin-app/src/components/ArtworkPicker.tsx @@ -282,9 +282,9 @@ export function ArtworkPicker({

    {!gameUuid ? ( -

    + Select a library title (search above, or Open picker from a queue group) to enable Apply. -

    + ) : (

    Target: {gameName || gameUuid}{' '} diff --git a/frontend/admin-app/src/components/BatchLibraryEditModal.css b/frontend/admin-app/src/components/BatchLibraryEditModal.css new file mode 100644 index 000000000..c81e0dda6 --- /dev/null +++ b/frontend/admin-app/src/components/BatchLibraryEditModal.css @@ -0,0 +1,78 @@ +.od-library-edit-modal { + position: fixed; + inset: 0; + z-index: 1300; + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; + padding: 1rem; + background: rgb(5 7 10 / 82%); + backdrop-filter: blur(8px); +} + +.od-library-edit-modal__panel { + width: min(38rem, 100%); + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.1rem; + border: 1px solid var(--od-border); + border-radius: var(--od-radius-2xl); + background: var(--od-surface); + color: var(--od-text); + box-shadow: 0 24px 64px rgb(0 0 0 / 45%); +} + +.od-library-edit-modal__header, +.od-library-edit-modal__actions { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; +} + +.od-library-edit-modal__header h2 { + margin: 0; + font-size: var(--od-font-lg); +} + +.od-library-edit-modal__header p { + margin-block: 0.35rem 0; +} + +.od-library-edit-modal__targets { + max-height: 9rem; + overflow: auto; + margin: 0; + padding-inline-start: 1.25rem; + color: var(--od-text-muted); +} + +.od-library-edit-modal__form { + display: grid; + gap: 0.85rem; +} + +.od-library-edit-modal__full { + justify-self: start; +} + +.od-library-edit-modal__actions { + justify-content: flex-end; + padding-block-start: 0.25rem; +} + +@media (max-width: 680px) { + .od-library-edit-modal { + align-items: flex-end; + padding: 0.5rem; + } + + .od-library-edit-modal__panel { + max-height: calc(100dvh - 1rem); + overflow-y: auto; + padding: 0.85rem; + } +} diff --git a/frontend/admin-app/src/components/BatchLibraryEditModal.tsx b/frontend/admin-app/src/components/BatchLibraryEditModal.tsx new file mode 100644 index 000000000..6909f3261 --- /dev/null +++ b/frontend/admin-app/src/components/BatchLibraryEditModal.tsx @@ -0,0 +1,204 @@ +import { useEffect, useId, useRef, useState, type FormEvent } from 'react' +import { Button, Modal, PageStatus } from '@oneirodex/ui' +import { getJson, postJsonResult } from '../api/adminApi' +import { BATCH_EDIT_URL } from './libraries/librariesModel' +import { errorText } from '../utils/errorText' +import { showToast } from '../utils/toast' +import './BatchLibraryEditModal.css' + +export type LibraryEditTarget = { uuid: string; name: string } +type PlatformOption = { key: string; label: string } + +export function BatchLibraryEditModal({ + targets, + onClose, + onApplied, +}: { + targets: LibraryEditTarget[] | null + onClose: () => void + onApplied: () => void +}) { + const titleId = useId() + const scanDepthRef = useRef(null) + const [platforms, setPlatforms] = useState([]) + const [platformError, setPlatformError] = useState('') + const [scanDepth, setScanDepth] = useState('') + const [watch, setWatch] = useState('') + const [platform, setPlatform] = useState('') + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (!targets) return + let cancelled = false + setScanDepth('') + setWatch('') + setPlatform('') + setPlatformError('') + getJson('/api/admin/library_platforms') + .then((data) => { + const options = Array.isArray(data?.platforms) ? data.platforms : [] + if (!cancelled) setPlatforms(options) + }) + .catch((error) => { + if (!cancelled) setPlatformError(errorText(error) || 'Unable to load platform choices.') + }) + return () => { + cancelled = true + } + }, [targets]) + + async function apply(event: FormEvent) { + event.preventDefault() + if (!targets?.length || busy) return + const patch: Record = {} + if (scanDepth) patch.scan_depth = Number(scanDepth) + if (watch === 'default') patch.watch_enabled = null + if (watch === 'on') patch.watch_enabled = true + if (watch === 'off') patch.watch_enabled = false + if (platform) patch.platform = platform + if (!Object.keys(patch).length) { + showToast('Choose at least one field to change.', 'info') + return + } + setBusy(true) + try { + const result = await postJsonResult(BATCH_EDIT_URL, { + library_uuids: targets.map((target) => target.uuid), + ...patch, + }) + if (!result.ok) { + showToast(result.data?.error || result.data?.message || 'Batch edit failed.', 'error') + return + } + const updated = Number(result.data?.updated) || 0 + const skipped = Number(result.data?.skipped) || 0 + const failed = Number(result.data?.failed) || 0 + const message = + result.data?.message || + (failed + ? `Edit: ${updated} updated, ${skipped} unchanged, ${failed} failed.` + : updated + ? `Updated ${updated} libraries${skipped ? ` (${skipped} unchanged)` : ''}.` + : skipped + ? 'No changes — selected libraries already match.' + : 'Edit applied.') + showToast(message, failed ? 'warn' : 'success') + onClose() + if (updated > 0) onApplied() + } catch (error) { + showToast(errorText(error) || 'Batch edit failed.', 'error') + } finally { + setBusy(false) + } + } + + if (!targets?.length) return null + const single = targets.length === 1 + return ( + +

    +
    +

    {single ? 'Edit library' : `Edit ${targets.length} libraries`}

    +

    + Apply shared fields + {single + ? ', or open the full editor for name and image.' + : '. Leave each field unchanged to skip it.'} +

    +
    + +
    + {!single ? ( +
      + {targets.map((target) => ( +
    • {target.name}
    • + ))} +
    + ) : null} + {platformError ? ( + + ) : null} +
    void apply(event)}> + + + + {single ? ( + + Open full editor + + ) : ( +

    Rename and library image stay on each full editor.

    + )} +
    + + +
    +
    + + ) +} diff --git a/frontend/admin-app/src/components/CommunityChatSettingsPanel.tsx b/frontend/admin-app/src/components/CommunityChatSettingsPanel.tsx new file mode 100644 index 000000000..b93489cfa --- /dev/null +++ b/frontend/admin-app/src/components/CommunityChatSettingsPanel.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { Button, PageStatus } from '@oneirodex/ui' +import { getJson, putJson } from '../api/adminApi' +import { errorText } from '../utils/errorText' + +const ENDPOINT = '/api/admin/integrations/community' + +export function CommunityChatSettingsPanel() { + const [url, setUrl] = useState('') + const [label, setLabel] = useState('Open community') + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [message, setMessage] = useState('') + + useEffect(() => { + let live = true + getJson(ENDPOINT) + .then((data) => { + if (live) { + setUrl(String(data.community_chat_url || '')) + setLabel(String(data.community_chat_label || 'Open community')) + } + }) + .catch((cause) => { + if (live) setError(errorText(cause) || 'Could not load the community link.') + }) + .finally(() => { + if (live) setLoading(false) + }) + return () => { + live = false + } + }, []) + + async function save(event: FormEvent) { + event.preventDefault() + if (loading || busy) return + setBusy(true) + setError('') + setMessage('') + try { + const data = await putJson(ENDPOINT, { + community_chat_url: url.trim(), + community_chat_label: label.trim(), + }) + setUrl(String(data.community_chat_url || '')) + setLabel(String(data.community_chat_label || 'Open community')) + setMessage('Community link saved.') + } catch (cause) { + setError(errorText(cause) || 'Could not save the community link.') + } finally { + setBusy(false) + } + } + + return ( +
    +

    + Community chat link +

    +

    + Add an optional Stoat or Matrix link for household members. This link does not connect an + account or share credentials. +

    + + {message} + +
    +
    + Public link + + +
    +

    Leave the URL blank to remove the link.

    +
    + + + Manage custom chat emoji + + + ROM reference sets + +
    +
    +
    + ) +} diff --git a/frontend/admin-app/src/components/CreateLibraryForm.tsx b/frontend/admin-app/src/components/CreateLibraryForm.tsx index 26d63fbb7..c38d711b5 100644 --- a/frontend/admin-app/src/components/CreateLibraryForm.tsx +++ b/frontend/admin-app/src/components/CreateLibraryForm.tsx @@ -1,11 +1,7 @@ import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from 'react' import { Button, Modal, PageStatus } from '@oneirodex/ui' -import { getJson } from '../api/adminApi' -import { - confirmCreateSelected, - type CandidateRow, - type LeafCreateResult, -} from '../api/proposeLeafLibrariesApi' +import { getJson, postForm } from '../api/adminApi' +import { queueLeafScan } from '../api/proposeLeafLibrariesApi' import { errorText } from '../utils/errorText' import './CreateLibraryForm.css' @@ -35,9 +31,11 @@ export function CreateLibraryForm({ onCreated }: { onCreated?: () => void }) { const [platform, setPlatform] = useState('') const [scanMode, setScanMode] = useState<'folders' | 'files'>('folders') const [depth, setDepth] = useState<1 | 2>(1) + const [watch, setWatch] = useState('default') + const [image, setImage] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState('') - const [result, setResult] = useState(null) + const [result, setResult] = useState<{ note?: string } | null>(null) const [browseOpen, setBrowseOpen] = useState(false) const [browseRoots, setBrowseRoots] = useState([]) const [browseRoot, setBrowseRoot] = useState('') @@ -70,27 +68,38 @@ export function CreateLibraryForm({ onCreated }: { onCreated?: () => void }) { setBusy(true) setError('') setResult(null) - const candidate: CandidateRow = { - id: `manual:${path.trim()}`, - path: path.trim(), - suggested_name: name.trim(), - platform, - scan_mode: scanMode, - scan_depth: depth, - reason: 'Admin-created library', - source_index: 0, + let uuid = '' + try { + const body = new FormData() + body.append('name', name.trim()) + body.append('platform', platform) + body.append('scan_depth', String(depth)) + body.append('watch_enabled', watch) + if (image) body.append('image', image, image.name) + const created = await postForm<{ library_uuid?: string }>('/api/admin/libraries', body) + uuid = String(created.library_uuid || '') + if (!uuid) throw new Error('Library was created but its ID was not returned.') + } catch (err) { + setError(errorText(err) || 'Library could not be created.') + setBusy(false) + return } + + setName('') + setPath('') + setImage(null) + onCreated?.() try { - const outcome = await confirmCreateSelected([candidate]) - setResult(outcome.results[0] || null) - if (outcome.created > 0) { - setName('') - setPath('') - onCreated?.() + const scan = await queueLeafScan({ uuid, path: path.trim(), scan_mode: scanMode }) + if (!scan.ok) { + setError(`Library created, but the first scan could not be queued: ${scan.error}`) + } else { + setResult({ note: scan.data?.message || 'Library created and first scan queued.' }) } - if (outcome.failed) setError(outcome.results[0]?.error || 'Library could not be created.') } catch (err) { - setError(errorText(err) || 'Library could not be created.') + setError( + `Library created, but first scan could not be queued: ${errorText(err) || 'request failed.'}`, + ) } finally { setBusy(false) } @@ -156,18 +165,20 @@ export function CreateLibraryForm({ onCreated }: { onCreated?: () => void }) { className="od-create-library__message od-create-library__message--error" />
    void submit(event)}> -