feat(permissions): gate emulator streaming and in-browser play behind group permissions (#5185)

* feat(permissions): gate emulator streaming behind a group permission

Add a streaming permission entity. Read shows streaming and allows joining
a session; write allows starting one. Users without read get a disabled
streaming config, so every streaming surface hides.

Migration 0152 grants read and write to every existing group, so nobody
loses access on upgrade. Groups created afterwards start without it.
Owner-scoped session controls (save, exit, release) stay ungated so a
revoked user can still leave a running game without losing saves.

* fix(streaming): pr-ready code-review fixes for the streaming grant

Own-only read no longer joins other users' sessions, the config refetches on permissions:changed, the store skips the joinable request when streaming is off, and tests clean up the groups they create.

* refactor(streaming): pr-ready simplify pass on the streaming grant

Collapse the streaming permission helpers onto assert_can/can_access, return an empty joinable list instead of 403, skip the permission resolve when streaming is off, and add a stream.start action key so the UI hides Stream (but keeps Join) for users without the write grant.

* docs(streaming): pr-ready review-polish pass on the streaming grant

Trim the 0152 migration docstring to a single summary line, matching 0150 and 0151.

* docs(streaming): pr-ready humanizer pass on the streaming grant

Plainer wording for the stream.start action key comment and the Permission Groups paragraph, which now says an own-only read shows streaming without the join.

* feat(permissions): gate in-browser emulation behind a group permission

Adds an emulation entity next to streaming. Read plays in the browser
(EmulatorJS, Ruffle, js-dos, PICO-8, EasyRPG) and joins netplay rooms,
write hosts them. Migration 0152 now backfills both entities onto every
existing group.

* fix(streaming): address Greptile review on PR 5185

- Pin the claimed container in the stream player, so a permission refresh
  that turns streaming off mid-game keeps save, load and disc swap until
  the session ends.
- Empty the Join list when a refreshed config turns streaming off, and
  discard any joinable request still in flight.
- Update the installPermissionsHydration doc to name every layout that
  calls it.

* fix(migrations): renumber play permissions migration to 0153 after master's 0152

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* chore(permissions): drop redundant PermEntity comments

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(streaming): drop the live config refresh on permission changes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Georges-Antoine Assi <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Zach Clendenen
2026-10-09 14:47:11 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Georges-Antoine Assi
parent 0a54359ab4
commit 1f6b4d11ca
53 changed files with 908 additions and 55 deletions
@@ -0,0 +1,70 @@
"""Grant streaming and emulation read and write to every existing permission group
Revision ID: 0153_play_permissions
Revises: 0152_cross_user_smart_scopes
Create Date: 2026-10-07 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0153_play_permissions"
down_revision = "0152_cross_user_smart_scopes"
branch_labels = None
depends_on = None
ENTITIES = ("streaming", "emulation")
ACTIONS = ("read", "write")
def _groups_t() -> sa.TableClause:
return sa.table("permission_groups", sa.column("id", sa.Integer))
def _grants_t() -> sa.TableClause:
return sa.table(
"permission_group_grants",
sa.column("group_id", sa.Integer),
sa.column("entity", sa.String),
sa.column("action", sa.String),
sa.column("own_only", sa.Boolean),
)
def _overrides_t() -> sa.TableClause:
return sa.table("user_permission_overrides", sa.column("entity", sa.String))
def upgrade() -> None:
conn = op.get_bind()
grants_t = _grants_t()
existing = {
(row.group_id, row.entity, row.action)
for row in conn.execute(
sa.select(grants_t.c.group_id, grants_t.c.entity, grants_t.c.action).where(
grants_t.c.entity.in_(ENTITIES)
)
)
}
group_ids: list[int] = list(conn.execute(sa.select(_groups_t().c.id)).scalars())
rows = [
{"group_id": group_id, "entity": entity, "action": action, "own_only": False}
for group_id in group_ids
for entity in ENTITIES
for action in ACTIONS
if (group_id, entity, action) not in existing
]
if rows:
conn.execute(grants_t.insert(), rows)
def downgrade() -> None:
# The previous PermEntity cannot load these rows, so none may remain.
conn = op.get_bind()
grants_t = _grants_t()
overrides_t = _overrides_t()
conn.execute(sa.delete(grants_t).where(grants_t.c.entity.in_(ENTITIES)))
conn.execute(sa.delete(overrides_t).where(overrides_t.c.entity.in_(ENTITIES)))
+6 -1
View File
@@ -5,9 +5,10 @@ from fastapi import Request
from decorators.auth import protected_route
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
from handler.auth.constants import Scope
from handler.auth.dependencies import assert_rom_visible
from handler.auth.dependencies import assert_rom_visible, can_access, get_permissions
from handler.database import db_rom_handler
from handler.netplay_handler import NetplayRoom, netplay_handler
from models.permission import PermAction, PermEntity
from utils.router import APIRouter
router = APIRouter(
@@ -56,6 +57,10 @@ async def get_rooms(request: Request, game_id: int) -> Dict[str, RoomsResponse]:
assert_rom_visible(request, rom)
# Empty rather than 403, like the streaming joinable list.
if not can_access(get_permissions(request), PermEntity.EMULATION, PermAction.READ):
return {}
netplay_rooms = await netplay_handler.get_all()
open_rooms: Dict[str, RoomsResponse] = {
+7 -1
View File
@@ -47,6 +47,10 @@ class ActionKey(enum.StrEnum):
PLAYLIST_EDIT = "playlist.edit"
# Library
LIBRARY_SCAN = "library.scan"
# Streaming read has no key: the UI reads the streaming config's `enabled`.
STREAM_START = "stream.start"
# Netplay (rom.play is the emulation read grant)
NETPLAY_HOST = "netplay.host"
# Users
USER_VIEW = "user.view"
USER_CREATE = "user.create"
@@ -63,7 +67,6 @@ class ActionKey(enum.StrEnum):
_ENTITY_ACTION_KEYS: dict[tuple[PermEntity, PermAction], tuple[ActionKey, ...]] = {
(PermEntity.ROMS, PermAction.READ): (
ActionKey.ROM_VIEW,
ActionKey.ROM_PLAY,
ActionKey.ROM_DOWNLOAD,
ActionKey.ROM_FAVORITE,
),
@@ -88,6 +91,9 @@ _ENTITY_ACTION_KEYS: dict[tuple[PermEntity, PermAction], tuple[ActionKey, ...]]
),
(PermEntity.COLLECTIONS, PermAction.DELETE): (ActionKey.COLLECTION_DELETE,),
(PermEntity.PLAYLISTS, PermAction.WRITE): (ActionKey.PLAYLIST_EDIT,),
(PermEntity.STREAMING, PermAction.WRITE): (ActionKey.STREAM_START,),
(PermEntity.EMULATION, PermAction.READ): (ActionKey.ROM_PLAY,),
(PermEntity.EMULATION, PermAction.WRITE): (ActionKey.NETPLAY_HOST,),
(PermEntity.USERS, PermAction.READ): (ActionKey.USER_VIEW,),
(PermEntity.USERS, PermAction.WRITE): (
ActionKey.USER_CREATE,
+9 -1
View File
@@ -11,10 +11,11 @@ from config import DEV_MODE, DISABLE_EASYRPG
from decorators.auth import protected_route
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
from handler.auth.constants import Scope
from handler.auth.dependencies import assert_rom_visible
from handler.auth.dependencies import assert_rom_visible, get_permissions
from handler.database import db_rom_handler
from handler.easyrpg import INDEX_FILE, RTP_WEB_PATH, easyrpg_handler
from handler.filesystem import fs_rom_handler
from models.permission import PermAction, PermEntity
from utils.nginx import FileRedirectResponse
from utils.router import APIRouter
@@ -49,6 +50,13 @@ async def get_easyrpg_file(
if DISABLE_EASYRPG:
raise _not_found("EasyRPG is disabled")
# Only the in-browser player reads these, and a game it runs is the user's own.
perms = get_permissions(request)
if not perms.allows(PermEntity.EMULATION, PermAction.READ, owned=True):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions"
)
# The player fetches every asset through here, so the full rom is never loaded.
rom = db_rom_handler.get_rom_visibility(id)
if not rom:
+26 -6
View File
@@ -15,6 +15,7 @@ from handler.database import db_rom_handler, db_user_handler
from handler.netplay_handler import NetplayPlayerInfo, NetplayRoom, netplay_handler
from handler.socket_handler import netplay_socket_handler
from logger.logger import log
from models.permission import PermAction, PermEntity
if TYPE_CHECKING:
from models.user import User
@@ -68,19 +69,30 @@ async def _authenticated_user(sid: str) -> User | None:
return user
async def _playable_rom_id(sid: str, game_id: Any) -> int | None:
"""The ROM id if the socket's user passes the gate the ROM endpoints apply, else None."""
async def _playable_rom_id(
sid: str, game_id: Any, *actions: PermAction, owned: bool
) -> int | None:
"""The ROM id if the socket's user may play it in the browser, else None.
Args:
actions: the emulation grants needed besides the ROM gate.
owned: whether the room is the user's own, where an "own items only" grant counts.
"""
user = await _authenticated_user(sid)
if user is None or Scope.ROMS_READ not in user.oauth_scopes:
return None
perms = resolve_permissions(user)
if not all(perms.allows(PermEntity.EMULATION, a, owned=owned) for a in actions):
return None
try:
rom_id = int(game_id)
except TypeError, ValueError:
return None
rom = db_rom_handler.get_rom_visibility(rom_id)
if rom is None or not resolve_permissions(user).can_see_rom(rom):
if rom is None or not perms.can_see_rom(rom):
return None
return rom.id
@@ -146,7 +158,9 @@ async def open_room(sid: str, data: RoomData) -> str | None:
if not session_id or not player_id:
return "Invalid data: sessionId and playerId required"
rom_id = await _playable_rom_id(sid, extra_data.get("game_id"))
rom_id = await _playable_rom_id(
sid, extra_data.get("game_id"), PermAction.READ, PermAction.WRITE, owned=True
)
if rom_id is None:
log.warning("Netplay room creation rejected: not authorized for this rom")
return "Not authorized to open a room for this game"
@@ -195,11 +209,17 @@ async def join_room(
if not current_room:
return "Room not found"
# A password room admits whoever has the password, any other needs the ROM.
# A password room admits whoever has the password, any other needs the ROM
# and a full emulation read, as the room is someone else's.
if current_room["password"]:
if not _password_matches(current_room["password"], _room_password(data)):
return "Incorrect password"
elif await _playable_rom_id(sid, current_room["game_id"]) is None:
elif (
await _playable_rom_id(
sid, current_room["game_id"], PermAction.READ, owned=False
)
is None
):
log.warning("Netplay join rejected: not authorized for this rom")
return "Not authorized to join this room"
+15
View File
@@ -7,6 +7,9 @@ is absent from READ_SCOPES -- which is all KIOSK_MODE hands an anonymous
visitor. Without it, kiosk visitors (who all share one synthetic user, so
session ownership cannot separate them) could claim sessions and overwrite
each other's save states.
On top of the scopes, the `streaming` permission grant decides who sees
streaming, joins a session (read) and starts one (read and write).
"""
import asyncio
@@ -332,6 +335,11 @@ def _check_key(kind: str, c: ResolvedContainer) -> Hashable:
@protected_route(router.get, "/config", [Scope.ROMS_READ])
async def get_config(request: Request) -> StreamingConfigSchema:
"""Return streaming configuration to the frontend"""
# The frontend hides every streaming surface on `enabled`.
if streaming_enabled() and not access.can_see_streaming(request):
return StreamingConfigSchema(
enabled=False, containers=[], emulator_labels=emulator_labels()
)
# One row per platform: a pool is a backend concern, the frontend picks a
# platform and the claim decides which container serves it.
# The record carries the platform's label and capabilities, so a
@@ -829,6 +837,8 @@ async def claim_session(
Returns 409 if every container in the platform's first pool is occupied.
Returns 428 if the container's pre-existing memory card needs a decision.
"""
access.assert_can_start_streaming(request)
rom = db_rom_handler.get_rom(req.rom_id)
if rom is None:
raise HTTPException(status_code=404, detail="ROM not found")
@@ -1308,6 +1318,8 @@ async def join_session(
through access.assert_session_owner, so a joiner cannot change the volume, write
states, or release the container.
"""
access.assert_can_join_streaming(request)
if container is not None:
candidate, _, session = await access.resolve_named_container(
platform, container
@@ -1733,6 +1745,9 @@ async def list_joinable_sessions(
sessions whose host opted into multiplayer at launch, and only the fields
a Join button needs. Sessions the caller is already hosting are left out.
"""
if not access.can_join_streaming(request):
return JoinableSessionsResponse(sessions=[])
grouped = containers_by_key()
sessions: list[JoinableSessionSchema] = []
+4
View File
@@ -92,6 +92,10 @@ LEGACY_VIEWER_GRANTS: tuple[Grant, ...] = (
(PermEntity.DEVICES, PermAction.READ, True),
(PermEntity.DEVICES, PermAction.WRITE, True),
(PermEntity.DEVICES, PermAction.DELETE, True),
(PermEntity.STREAMING, PermAction.READ, False),
(PermEntity.STREAMING, PermAction.WRITE, False),
(PermEntity.EMULATION, PermAction.READ, False),
(PermEntity.EMULATION, PermAction.WRITE, False),
)
# "Editor" == EDIT_SCOPES: viewer + library-wide write AND delete of
+34 -1
View File
@@ -4,7 +4,12 @@ from typing import Any
from fastapi import HTTPException, Request
from handler.auth.dependencies import get_permissions, get_rom_visibility_filter
from handler.auth.dependencies import (
assert_can,
can_access,
get_permissions,
get_rom_visibility_filter,
)
from handler.database import db_platform_handler, db_rom_handler
from handler.streaming.config import (
ResolvedContainer,
@@ -19,6 +24,7 @@ from handler.streaming.session_store import (
session_platform_matches,
)
from logger.logger import log
from models.permission import PermAction, PermEntity
from models.rom import Rom
from models.user import Role
@@ -43,6 +49,33 @@ def assert_session_owner(session: dict[str, Any], request: Request) -> None:
raise HTTPException(status_code=403, detail="Session is claimed by another user")
def _allows_streaming(request: Request, *actions: PermAction) -> bool:
# A session is always the caller's own, so an "own items only" grant counts.
perms = get_permissions(request)
return all(perms.allows(PermEntity.STREAMING, a, owned=True) for a in actions)
def can_see_streaming(request: Request) -> bool:
"""Whether the caller may see streaming and play their own sessions."""
return _allows_streaming(request, PermAction.READ)
def can_join_streaming(request: Request) -> bool:
"""Whether the caller may join someone else's session."""
return can_access(get_permissions(request), PermEntity.STREAMING, PermAction.READ)
def assert_can_join_streaming(request: Request) -> None:
"""Raise 403 unless the caller may join someone else's session."""
assert_can(get_permissions(request), PermEntity.STREAMING, PermAction.READ)
def assert_can_start_streaming(request: Request) -> None:
"""Raise 403 unless the caller may start a session, which takes a container."""
if not _allows_streaming(request, PermAction.READ, PermAction.WRITE):
raise HTTPException(status_code=403, detail="Insufficient permissions")
def rom_is_visible(request: Request, rom: Rom | None) -> bool:
"""Can the caller see this ROM?
+2
View File
@@ -40,6 +40,8 @@ class PermEntity(enum.StrEnum):
USERS = "users"
TASKS = "tasks"
LOGS = "logs"
STREAMING = "streaming"
EMULATION = "emulation"
class PermAction(enum.StrEnum):
+33 -1
View File
@@ -7,7 +7,12 @@ from endpoints.roms import easyrpg
from handler.database import db_rom_handler
from handler.database.base_handler import sync_session
from handler.easyrpg import easyrpg_handler
from models.permission import HiddenEntity, PermEntity
from models.permission import (
HiddenEntity,
PermAction,
PermEntity,
UserPermissionOverride,
)
from models.platform import Platform
from models.rom import Rom, RomFile
from models.user import User
@@ -108,6 +113,33 @@ def test_a_hidden_game_is_not_found(
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"own_only, expected",
[(False, status.HTTP_403_FORBIDDEN), (True, status.HTTP_200_OK)],
ids=["revoked", "own-only"],
)
def test_needs_the_emulation_read_grant(
client: TestClient, editor_headers, editor_user: User, game: Rom, own_only, expected
):
"""The player is the only reader, and a game it runs counts as the user's own."""
with sync_session.begin() as s:
s.add(
UserPermissionOverride(
user_id=editor_user.id,
entity=PermEntity.EMULATION,
action=PermAction.READ,
granted=own_only,
own_only=own_only,
)
)
response = client.get(
f"/api/roms/{game.id}/easyrpg/index.json", headers=editor_headers
)
assert response.status_code == expected
def test_is_not_found_while_disabled(mocker, client: TestClient, headers, game: Rom):
mocker.patch.object(easyrpg, "DISABLE_EASYRPG", True)
@@ -23,6 +23,7 @@ from handler.auth.constants import Scope
from handler.database import db_rom_handler, db_user_handler
from handler.netplay_handler import NetplayPlayerInfo, NetplayRoom
from handler.socket_handler import netplay_socket_handler
from models.permission import PermAction, PermEntity
ROM_ID = 42
PLATFORM_ID = 7
@@ -30,6 +31,16 @@ VISIBLE_ROM = Mock(id=ROM_ID, platform_id=PLATFORM_ID)
ROOM_1 = netplay_module._socket_room("room-1")
def _grant(rooms: Mock, *actions: PermAction, own_only: bool = False) -> None:
"""Hold only these emulation grants, as library-wide or own-only ones."""
def allows(entity: PermEntity, action: PermAction, *, owned: bool | None = None):
held = entity == PermEntity.EMULATION and action in actions
return held and (not own_only or owned is True)
rooms.permissions.return_value.allows = Mock(side_effect=allows)
def _user(*scopes: Scope) -> Mock:
user = Mock()
user.id = 1
@@ -118,6 +129,7 @@ def rooms(mocker) -> Mock:
mocker.patch.object(db_rom_handler, "get_rom_visibility", return_value=VISIBLE_ROM)
permissions = mocker.patch.object(netplay_module, "resolve_permissions")
permissions.return_value.can_see_rom = Mock(return_value=True)
permissions.return_value.allows = Mock(return_value=True)
return Mock(store=store, handler=handler, permissions=permissions)
@@ -258,6 +270,29 @@ class TestOpenRoomAuthorization:
assert server.sessions["sid"][AUTH_USER_SESSION_KEY] == 1
assert server.sessions["sid"]["session_id"] == "room-1"
@pytest.mark.parametrize(
"held",
[(), (PermAction.READ,), (PermAction.WRITE,)],
ids=["none", "read", "write"],
)
async def test_rejects_without_emulation_read_and_write(
self, server, rooms, authorized, held
):
_grant(rooms, *held)
result = await open_room("sid", _open())
assert result is not None and "Not authorized" in result
rooms.handler.set.assert_not_awaited()
async def test_an_own_only_grant_hosts(self, server, rooms, authorized):
"""The room is the host's own, so an "own items only" grant counts."""
_grant(rooms, PermAction.READ, PermAction.WRITE, own_only=True)
assert await open_room("sid", _open()) is None
assert "room-1" in rooms.store
class TestJoinRoomAuthorization:
async def test_rejects_guest_without_a_password(self, mocker, server, rooms):
@@ -335,6 +370,43 @@ class TestJoinRoomAuthorization:
assert joined is not None
server.enter_room.assert_awaited_once_with("sid", ROOM_1)
async def test_rejects_user_without_emulation_read(self, server, rooms, authorized):
rooms.store["room-1"] = _room()
_grant(rooms, PermAction.WRITE)
result = await join_room("sid", _join())
assert "Not authorized" in result
server.enter_room.assert_not_awaited()
async def test_rejects_an_own_only_read(self, server, rooms, authorized):
"""The room is someone else's, so an "own items only" grant does not reach it."""
rooms.store["room-1"] = _room()
_grant(rooms, PermAction.READ, own_only=True)
result = await join_room("sid", _join())
assert "Not authorized" in result
server.enter_room.assert_not_awaited()
async def test_joins_with_emulation_read_alone(self, server, rooms, authorized):
rooms.store["room-1"] = _room()
_grant(rooms, PermAction.READ)
assert await join_room("sid", _join()) is not None
server.enter_room.assert_awaited_once_with("sid", ROOM_1)
async def test_a_password_invite_needs_no_emulation_grant(
self, server, rooms, authorized
):
rooms.store["room-1"] = _room(password="s3cret")
_grant(rooms)
assert await join_room("sid", _join(password="s3cret")) is not None
server.enter_room.assert_awaited_once_with("sid", ROOM_1)
class TestWebRtcSignalRelay:
async def test_drops_a_signal_to_a_non_peer(self, mocker, server, rooms):
+33 -1
View File
@@ -11,7 +11,12 @@ from handler.auth.base_handler import oauth_handler
from handler.auth.constants import Scope
from handler.database.base_handler import sync_session
from handler.netplay_handler import NetplayPlayerInfo, NetplayRoom, netplay_handler
from models.permission import HiddenEntity, PermEntity
from models.permission import (
HiddenEntity,
PermAction,
PermEntity,
UserPermissionOverride,
)
from models.user import User
@@ -95,3 +100,30 @@ def test_listing_needs_the_rom_read_scope(client, viewer_user, rooms):
assert resp.status_code == 403
rooms.assert_not_awaited()
def _override_emulation_read(user: User, *, own_only: bool = False) -> None:
with sync_session.begin() as s:
s.add(
UserPermissionOverride(
user_id=user.id,
entity=PermEntity.EMULATION,
action=PermAction.READ,
granted=own_only,
own_only=own_only,
)
)
@pytest.mark.parametrize("own_only", [False, True], ids=["revoked", "own-only"])
def test_listing_is_empty_without_a_full_emulation_read(
client, viewer_user, rom, rooms, own_only
):
"""Every listed room is someone else's, so only a library-wide grant shows them."""
_override_emulation_read(viewer_user, own_only=own_only)
rooms.return_value = {"room-1": _room(rom.id)}
resp = client.get(f"/api/netplay/list?game_id={rom.id}", headers=_auth(viewer_user))
assert resp.status_code == 200
assert resp.json() == {}
+54 -1
View File
@@ -1,7 +1,14 @@
"""`/permissions/me` returns backend-enforced grants in the UI action vocabulary."""
import pytest
from handler.database.base_handler import sync_session
from models.permission import HiddenEntity, PermEntity
from models.permission import (
HiddenEntity,
PermAction,
PermEntity,
UserPermissionOverride,
)
VIEWER_ACTIONS = {
"rom.view",
@@ -14,6 +21,8 @@ VIEWER_ACTIONS = {
"collection.edit",
"collection.delete",
"playlist.edit",
"stream.start",
"netplay.host",
}
@@ -94,3 +103,47 @@ def test_grant_scopes_are_global(client, viewer_access_token):
headers={"Authorization": f"Bearer {viewer_access_token}"},
).json()
assert all(g["scope"]["kind"] == "global" for g in body["grants"])
def test_revoking_streaming_write_drops_stream_start(
client, viewer_user, viewer_access_token
):
with sync_session.begin() as s:
s.add(
UserPermissionOverride(
user_id=viewer_user.id,
entity=PermEntity.STREAMING,
action=PermAction.WRITE,
granted=False,
)
)
body = client.get(
"/api/permissions/me",
headers={"Authorization": f"Bearer {viewer_access_token}"},
).json()
assert "stream.start" not in _actions(body)
@pytest.mark.parametrize(
"action, dropped",
[(PermAction.READ, "rom.play"), (PermAction.WRITE, "netplay.host")],
)
def test_revoking_emulation_drops_its_key(
client, viewer_user, viewer_access_token, action, dropped
):
with sync_session.begin() as s:
s.add(
UserPermissionOverride(
user_id=viewer_user.id,
entity=PermEntity.EMULATION,
action=action,
granted=False,
)
)
body = client.get(
"/api/permissions/me",
headers={"Authorization": f"Bearer {viewer_access_token}"},
).json()
actions = _actions(body)
assert dropped not in actions
assert "rom.view" in actions
+186 -2
View File
@@ -94,10 +94,17 @@ from handler.streaming.session_store import (
)
from models.assets import MemoryCard, MemoryCardVersion, Save, Screenshot, State
from models.notification import NotificationKind
from models.permission import HiddenEntity, PermEntity
from models.permission import (
HiddenEntity,
PermAction,
PermEntity,
PermissionGroup,
PermissionGroupGrant,
UserPermissionOverride,
)
from models.platform import Platform
from models.rom import Rom, RomFile, SaveTargetLayout
from models.user import User
from models.user import Role, User
from utils.memory_cards import content_hash_of_bytes
from utils.zip_cache import ensure_zipfile_writable
@@ -12855,3 +12862,180 @@ async def test_store_save_asset_fits_a_long_multibyte_rom_name(
assert filename.startswith("ゲーム")
assert filename.endswith("].saves.zip")
add_save.assert_called_once()
# ── streaming permission ──────────────────────────────────────────────────────
def _override_streaming(
user: User, *actions: PermAction, granted: bool = False, own_only: bool = False
) -> None:
with sync_session.begin() as s:
for action in actions:
s.add(
UserPermissionOverride(
user_id=user.id,
entity=PermEntity.STREAMING,
action=action,
granted=granted,
own_only=own_only,
)
)
def test_get_config_is_disabled_without_streaming_read(
client, viewer_access_token, viewer_user: User, rom: Rom
):
"""A user kept off streaming gets the same answer as a server without it."""
_override_streaming(viewer_user, PermAction.READ)
with _streaming(_container_for(rom)):
response = client.get(
"/api/streaming/config", headers=_auth(viewer_access_token)
)
assert response.status_code == 200
assert response.json()["enabled"] is False
assert response.json()["containers"] == []
@pytest.fixture
def guests_group() -> Iterator[int]:
"""A group with no streaming grant; conftest keeps groups between tests."""
with sync_session.begin() as s:
group = PermissionGroup(
name="Guests",
grants=[
PermissionGroupGrant(entity=PermEntity.ROMS, action=PermAction.READ)
],
)
s.add(group)
s.flush()
group_id = group.id
yield group_id
with sync_session.begin() as s:
s.query(PermissionGroup).filter_by(id=group_id).delete()
def test_get_config_is_disabled_for_a_group_without_streaming(
client, rom: Rom, guests_group: int
):
"""A group an admin creates starts with no streaming grant."""
guest = db_user_handler.add_user(
User(username="guest", role=Role.USER, permission_group_id=guests_group)
)
with _streaming(_container_for(rom)):
response = client.get(
"/api/streaming/config", headers=_auth(_access_token(guest))
)
assert response.json()["enabled"] is False
def test_get_config_keeps_streaming_for_an_admin_with_no_grants(
client, access_token, rom: Rom
):
with _streaming(_container_for(rom)):
response = client.get("/api/streaming/config", headers=_auth(access_token))
assert response.json()["enabled"] is True
@pytest.mark.parametrize("action", [PermAction.READ, PermAction.WRITE])
def test_claim_without_streaming_grant_is_forbidden(
client, viewer_access_token, viewer_user: User, rom: Rom, action: PermAction
):
"""Starting a session takes a container, so it needs both grants."""
_override_streaming(viewer_user, action)
with _streaming(_container_for(rom)):
with patch("handler.streaming.commands.launch") as call_broker:
r = _claim(client, viewer_access_token, rom.id)
assert r.status_code == 403
call_broker.assert_not_called()
def test_an_own_only_streaming_grant_counts(
client, viewer_access_token, viewer_user: User, rom: Rom
):
"""A session the player starts is their own, so "own items only" counts."""
_override_streaming(
viewer_user, PermAction.READ, PermAction.WRITE, granted=True, own_only=True
)
with _streaming(_container_for(rom)):
config = client.get("/api/streaming/config", headers=_auth(viewer_access_token))
r = _claim_ok(client, viewer_access_token, rom.id)
assert config.json()["enabled"] is True
assert r.status_code == 202
def test_claim_with_streaming_grant_still_launches(
client, viewer_access_token, rom: Rom
):
with _streaming(_container_for(rom)):
r = _claim_ok(client, viewer_access_token, rom.id)
assert r.status_code == 202
def test_joining_without_streaming_read_is_forbidden(
client, access_token, viewer_access_token, viewer_user: User, rom: Rom
):
_override_streaming(viewer_user, PermAction.READ)
with _streaming(_ws_for(rom)):
_claim_multiplayer(client, access_token, rom.id)
with patch("handler.streaming.webstation.join") as join_broker:
response = _join(client, viewer_access_token, rom.platform_slug)
assert response.status_code == 403
join_broker.assert_not_called()
def test_joining_needs_only_streaming_read(
client, access_token, viewer_access_token, viewer_user: User, rom: Rom
):
"""A guest seat runs on the host's container, so it costs no launch."""
_override_streaming(viewer_user, PermAction.WRITE)
with _streaming(_ws_for(rom)):
_claim_multiplayer(client, access_token, rom.id)
body = _joinable(client, viewer_access_token).json()
with _joined_room():
joined = _join(client, viewer_access_token, rom.platform_slug)
assert len(body["sessions"]) == 1
assert joined.status_code == 200
def test_an_own_only_streaming_read_cannot_join_someone_elses_session(
client, access_token, viewer_access_token, viewer_user: User, rom: Rom
):
"""Another player's session is not the caller's own."""
_override_streaming(viewer_user, PermAction.READ, granted=True, own_only=True)
with _streaming(_ws_for(rom)):
_claim_multiplayer(client, access_token, rom.id)
listed = _joinable(client, viewer_access_token)
with patch("handler.streaming.webstation.join") as join_broker:
joined = _join(client, viewer_access_token, rom.platform_slug)
assert listed.status_code == 200
assert listed.json()["sessions"] == []
assert joined.status_code == 403
join_broker.assert_not_called()
def test_joinable_is_empty_without_streaming_read(
client, access_token, viewer_access_token, viewer_user: User, rom: Rom
):
_override_streaming(viewer_user, PermAction.READ)
with _streaming(_ws_for(rom)):
_claim_multiplayer(client, access_token, rom.id)
response = _joinable(client, viewer_access_token)
assert response.status_code == 200
assert response.json()["sessions"] == []
def test_a_revoked_player_can_still_leave_their_session(
client, viewer_access_token, viewer_user: User, rom: Rom
):
"""Revoking streaming mid-game must not strand the player's save."""
with _streaming(_container_for(rom)):
assert _claim_ok(client, viewer_access_token, rom.id).status_code == 202
_override_streaming(viewer_user, PermAction.READ, PermAction.WRITE)
with _stub_stop():
r = client.delete(
f"/api/streaming/sessions/{rom.platform_slug}",
headers=_auth(viewer_access_token),
)
assert r.status_code == 200
assert r.json()["status"] == "released"
+98
View File
@@ -1121,3 +1121,101 @@ def test_the_sibling_platform_names_revision_renames_only_the_stale_name():
("c64", "Commodore 64"),
("c128-custom", "C128 (custom)"),
}
_GROUPS = sa.table(
"permission_groups",
sa.column("id", sa.Integer),
sa.column("name", sa.String),
sa.column("description", sa.String),
sa.column("is_default", sa.Boolean),
sa.column("hide_unrated_roms", sa.Boolean),
)
_GRANTS = sa.table(
"permission_group_grants",
sa.column("group_id", sa.Integer),
sa.column("entity", sa.String),
sa.column("action", sa.String),
sa.column("own_only", sa.Boolean),
)
_OVERRIDES = sa.table(
"user_permission_overrides",
sa.column("user_id", sa.Integer),
sa.column("entity", sa.String),
sa.column("action", sa.String),
sa.column("granted", sa.Boolean),
sa.column("own_only", sa.Boolean),
)
_PLAY_ENTITIES = ("streaming", "emulation")
def _play_grants(connection: sa.Connection) -> set[tuple[int, str, str, bool]]:
return {
(row.group_id, row.entity, row.action, bool(row.own_only))
for row in connection.execute(
sa.select(
_GRANTS.c.group_id,
_GRANTS.c.entity,
_GRANTS.c.action,
_GRANTS.c.own_only,
).where(_GRANTS.c.entity.in_(_PLAY_ENTITIES))
)
}
def _play_overrides(connection: sa.Connection) -> int:
return connection.execute(
sa.select(sa.func.count())
.select_from(_OVERRIDES)
.where(_OVERRIDES.c.entity.in_(_PLAY_ENTITIES))
).scalar_one()
def test_the_play_permissions_revision_backfills_reverses_and_replays(
admin_user: User,
):
"""0153 grants streaming and emulation to every existing group and downgrades cleanly."""
migration = _load_migration("0153_play_permissions.py")
with sync_engine.begin() as connection:
connection.execute(
_GROUPS.insert().values(
name="Locked down",
description="",
is_default=False,
hide_unrated_roms=False,
)
)
connection.execute(
_OVERRIDES.insert(),
[
{
"user_id": admin_user.id,
"entity": entity,
"action": "write",
"granted": False,
"own_only": False,
}
for entity in _PLAY_ENTITIES
],
)
with Operations.context(MigrationContext.configure(connection)):
migration.downgrade()
migration.downgrade()
assert _play_grants(connection) == set()
assert _play_overrides(connection) == 0
migration.upgrade()
migration.upgrade()
group_ids: set[int] = set(connection.execute(sa.select(_GROUPS.c.id)).scalars())
assert len(group_ids) >= 3
assert _play_grants(connection) == {
(group_id, entity, action, False)
for group_id in group_ids
for entity in _PLAY_ENTITIES
for action in ("read", "write")
}
connection.execute(_GROUPS.delete().where(_GROUPS.c.name == "Locked down"))
+6
View File
@@ -1292,6 +1292,12 @@ users.read / users.write : User management (admin)
tasks.run : Task execution
```
### Permission Groups
Endpoints also check the caller's permission group (`handler/auth/permissions.py`), which holds entity and action grants plus per-user overrides. Most entities line up with a scope above; `streaming` and `emulation` have none. A `read` grant shows streaming and lets the user join someone else's session, while an "own items only" `read` shows it without the join. Starting a session takes `read` and `write`. A user who loses the grant keeps control of a session they already hold.
`emulation` covers the in-browser players (EmulatorJS, Ruffle, js-dos, PICO-8, EasyRPG). A `read` grant lets the user play in the browser and join someone else's netplay room, and an "own items only" `read` plays without the join. Hosting a room takes `read` and `write`. A password room admits anyone with the password, so the invite stands in for the grant. ROM files are also served for downloads, so the grant hides the players in the UI and is enforced on the netplay sockets, the room list and the EasyRPG file route.
### CSRF Protection
- Cookie: `romm_csrftoken` (signed with `itsdangerous`)
+1 -1
View File
@@ -2,4 +2,4 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ActionKey = 'rom.view' | 'rom.play' | 'rom.download' | 'rom.upload' | 'rom.edit' | 'rom.delete' | 'rom.match' | 'rom.refresh' | 'rom.favorite' | 'platform.view' | 'platform.create' | 'platform.edit' | 'platform.delete' | 'collection.view' | 'collection.create' | 'collection.edit' | 'collection.delete' | 'playlist.edit' | 'library.scan' | 'user.view' | 'user.create' | 'user.edit' | 'user.delete' | 'app.admin';
export type ActionKey = 'rom.view' | 'rom.play' | 'rom.download' | 'rom.upload' | 'rom.edit' | 'rom.delete' | 'rom.match' | 'rom.refresh' | 'rom.favorite' | 'platform.view' | 'platform.create' | 'platform.edit' | 'platform.delete' | 'collection.view' | 'collection.create' | 'collection.edit' | 'collection.delete' | 'playlist.edit' | 'library.scan' | 'stream.start' | 'netplay.host' | 'user.view' | 'user.create' | 'user.edit' | 'user.delete' | 'app.admin';
+1 -1
View File
@@ -9,4 +9,4 @@
* grant matrix can be projected back onto the legacy ``Scope`` set without
* drift (see ``handler/auth/permissions_map.py``).
*/
export type PermEntity = 'platforms' | 'roms' | 'collections' | 'playlists' | 'firmware' | 'assets' | 'devices' | 'users' | 'tasks' | 'logs';
export type PermEntity = 'platforms' | 'roms' | 'collections' | 'playlists' | 'firmware' | 'assets' | 'devices' | 'users' | 'tasks' | 'logs' | 'streaming' | 'emulation';
@@ -10,6 +10,7 @@ import storeDownload from "@/stores/download";
import storeHeartbeat from "@/stores/heartbeat";
import type { SimpleRom } from "@/stores/roms";
import { isEJSEmulationSupported, isRuffleEmulationSupported } from "@/utils";
import { useCan } from "@/v2/composables/useCan";
const props = defineProps<{ rom: SimpleRom; sizeActionBar: number }>();
const { t } = useI18n();
@@ -28,7 +29,9 @@ const isAprilFools = computed(() => {
return today.getMonth() === 3 && today.getDate() === 1;
});
const canPlayInBrowser = useCan("rom.play");
const isEmulationSupported = computed(() => {
if (!canPlayInBrowser.value) return false;
return (
isEJSEmulationSupported(
props.rom.platform_slug,
@@ -13,6 +13,7 @@ import storeHeartbeat from "@/stores/heartbeat";
import storeRoms, { type SimpleRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { isEJSEmulationSupported, isRuffleEmulationSupported } from "@/utils";
import { useCan } from "@/v2/composables/useCan";
const props = defineProps<{ rom: SimpleRom; iconEmbedded?: boolean }>();
const attrs = useAttrs();
@@ -29,7 +30,9 @@ const isAprilFools = computed(() => {
return today.getMonth() === 3 && today.getDate() === 1;
});
const canPlayInBrowser = useCan("rom.play");
const isEmulationSupported = computed(() => {
if (!canPlayInBrowser.value) return false;
return (
isEJSEmulationSupported(
props.rom.platform_slug,
+4
View File
@@ -11,6 +11,7 @@ import { ROUTES } from "@/plugins/router";
import storeCollections from "@/stores/collections";
import storeNavigation from "@/stores/navigation";
import storePlatforms from "@/stores/platforms";
import { installPermissionsHydration } from "@/v2/composables/useCan";
const router = useRouter();
const bus = new InputBus();
@@ -21,6 +22,9 @@ const navigationStore = storeNavigation();
const platformsStore = storePlatforms();
const collectionsStore = storeCollections();
// Play reads the in-browser play grant from the permissions store.
installPermissionsHydration();
const showVirtualCollections = useLocalStorage(
"settings.showVirtualCollections",
true,
+5 -1
View File
@@ -30,6 +30,7 @@ import {
getUnmatchedCoverImage,
EXTENSION_REGEX,
} from "@/utils/covers";
import { useCan } from "@/v2/composables/useCan";
type FocusZone =
"play" | "description" | "details" | "shots" | "lightbox" | "states";
@@ -61,8 +62,11 @@ const descriptionOverlayRef = useTemplateRef<HTMLDivElement>(
"description-overlay-ref",
);
const detailsOverlayRef = useTemplateRef<HTMLDivElement>("details-overlay-ref");
const canPlayInBrowser = useCan("rom.play");
const isConsoleEmulationDisabled = computed(
() => heartbeatStore.value.EMULATION.DISABLE_EMULATOR_JS,
() =>
heartbeatStore.value.EMULATION.DISABLE_EMULATOR_JS ||
!canPlayInBrowser.value,
);
const releaseDate = computed(() => {
+4
View File
@@ -30,11 +30,15 @@ import storeCollections from "@/stores/collections";
import storeNavigation from "@/stores/navigation";
import storePlatforms from "@/stores/platforms";
import type { Events } from "@/types/emitter";
import { installPermissionsHydration } from "@/v2/composables/useCan";
const navigationStore = storeNavigation();
const platformsStore = storePlatforms();
const collectionsStore = storeCollections();
// The play buttons read the in-browser play grant from the permissions store.
installPermissionsHydration();
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("refreshDrawer", async () => {
platformsStore.fetchPlatforms();
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Играй в браузъра",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Hrát v prohlížeči",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Im Browser spielen",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Play in browser",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Play in browser",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Jugar en el navegador",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Jouer dans le navigateur",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Játék a böngészőben",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Salvataggi & stati",
"collections": "Collezioni",
"devices": "Dispositivi",
"emulation": "Gioca nel browser",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Piattaforme",
"playlists": "Playlist",
"roms": "Giochi",
"streaming": "Streaming",
"tasks": "Attività",
"users": "Utenti"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "ブラウザでプレイ",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "브라우저에서 플레이",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Zagraj w przeglądarce",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves e states",
"collections": "Coleções",
"devices": "Dispositivos",
"emulation": "Jogar no navegador",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Plataformas",
"playlists": "Listas de reprodução",
"roms": "Jogos",
"streaming": "Streaming",
"tasks": "Tarefas",
"users": "Usuários"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Joacă în browser",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Играть в браузере",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "Tarayıcıda oyna",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "存档与状态",
"collections": "收藏",
"devices": "设备",
"emulation": "在浏览器中游玩",
"firmware": "固件",
"logs": "日志",
"platforms": "平台",
"playlists": "播放列表",
"roms": "游戏",
"streaming": "串流",
"tasks": "任务",
"users": "用户"
},
+2
View File
@@ -334,11 +334,13 @@
"assets": "Saves & states",
"collections": "Collections",
"devices": "Devices",
"emulation": "在瀏覽器中遊玩",
"firmware": "Firmware",
"logs": "Logs",
"platforms": "Platforms",
"playlists": "Playlists",
"roms": "Games",
"streaming": "Streaming",
"tasks": "Tasks",
"users": "Users"
},
+4 -4
View File
@@ -1,10 +1,10 @@
// permissions: Pinia store holding the current user's effective grants.
//
// Hydrated from the backend's `/permissions/me` (the source of truth) via
// `installPermissionsHydration()` in AppLayout, and refreshed live on the
// `permissions:changed` socket event. v2 features consume this store via
// `useCan(action, scope?)`. v1 does not import from here: its existing inline
// scope checks keep working.
// `installPermissionsHydration()` in each layout, and refreshed live on the
// `permissions:changed` socket event. Features consume this store via
// `useCan(action, scope?)`. v1 and console mode read only `rom.play` here;
// their other checks stay on scopes.
import { defineStore } from "pinia";
import type {
PermissionScopeSchema,
+9
View File
@@ -143,6 +143,15 @@ describe("joinable sessions", () => {
expect(store.joinableForRom(7)).toBeNull();
});
it("asks for nothing once the config says streaming is off", async () => {
const store = useStreamingStore();
store.configLoaded = true;
await store.fetchJoinableSessions(true);
expect(listJoinableSessions).not.toHaveBeenCalled();
});
it("keeps the last known list when a refresh fails", async () => {
const store = useStreamingStore();
await store.fetchJoinableSessions();
+11 -1
View File
@@ -101,7 +101,14 @@ export const useStreamingStore = defineStore("streaming", () => {
supportsDiscSwap: boolean;
hasManualDiscSwap: boolean;
} {
const caps = containerForPlatform(slug)?.capabilities;
return containerCapabilities(containerForPlatform(slug));
}
/** The same capability set for a container already in hand. */
function containerCapabilities(
container: StreamingContainer | null | undefined,
): ReturnType<typeof platformCapabilities> {
const caps = container?.capabilities;
if (!caps) return { ...NO_CAPABILITIES };
return {
maxSlots: caps.max_slots,
@@ -214,6 +221,8 @@ export const useStreamingStore = defineStore("streaming", () => {
* the user is about to act on.
*/
async function fetchJoinableSessions(force = false): Promise<void> {
// Streaming is off for this user, so there is nothing to join.
if (configLoaded.value && !isEnabled.value) return;
if (joinableRequest) return joinableRequest;
if (!force && Date.now() - joinableFetchedAt < JOINABLE_MAX_AGE_MS) return;
@@ -422,6 +431,7 @@ export const useStreamingStore = defineStore("streaming", () => {
containerLabelForPlatform,
emulatorLabel,
platformCapabilities,
containerCapabilities,
fetchConfig,
reset,
claimSession,
+2 -1
View File
@@ -22,7 +22,8 @@ export type { ActionKey, Grant, PermissionScope };
/** Mount-time helper: keep permissionsStore in sync with the current user by
* fetching /permissions/me on login and on the `permissions:changed` socket
* event. Call once high in the v2 tree (AppLayout): the watch is idempotent. */
* event. Call once high in each layout (v2 AppLayout, v1 Main, console
* Layout): the watch is idempotent. */
export function installPermissionsHydration() {
const auth = storeAuth();
const permissions = storePermissions();
@@ -37,6 +37,12 @@ vi.mock("@/stores/streaming", () => ({
containerForPlatform: () => streamContainer.value,
}),
}));
const canStartStream = ref(true);
const canPlayInBrowser = ref(true);
vi.mock("@/v2/composables/useCan", () => ({
useCan: (action: string) =>
action === "rom.play" ? canPlayInBrowser : canStartStream,
}));
vi.mock("@/stores/native", () => ({
useNativeStore: () => ({
isSupportedPlatform: () => nativeSupported.value,
@@ -72,6 +78,8 @@ beforeEach(() => {
support.ruffle.mockReturnValue(false);
support.jsDosBundle.mockReturnValue(true);
streamContainer.value = null;
canStartStream.value = true;
canPlayInBrowser.value = true;
nativeSupported.value = false;
});
@@ -162,6 +170,43 @@ describe("useCanPlay", () => {
expect(canPlay.value).toBe(false);
});
it.each([
["EJS", "ejs", "canPlayEJS", []],
["js-dos", "jsDos", "canPlayJsDos", []],
["PICO-8", "pico8", "canPlayPico8", ["pico8Rom"]],
["EasyRPG", "easyRpg", "canPlayEasyRpg", ["easyRpgGame"]],
["Ruffle", "ruffle", "canPlayRuffle", []],
] as const)(
"withholds %s without the in-browser play grant",
(_label, stub, flag, alsoRequires) => {
support[stub].mockReturnValue(true);
for (const extra of alsoRequires) support[extra].mockReturnValue(true);
canPlayInBrowser.value = false;
const result = useCanPlay(() => playableRom());
expect(result[flag].value).toBe(false);
expect(result.canPlay.value).toBe(false);
},
);
it("still streams without the in-browser play grant", () => {
streamContainer.value = {};
canPlayInBrowser.value = false;
const { canPlay, canPlayStream } = useCanPlay(() => playableRom());
expect(canPlayStream.value).toBe(true);
expect(canPlay.value).toBe(true);
});
it("reaches a stream it may not start", () => {
streamContainer.value = {};
canStartStream.value = false;
const { canReachStream, canPlayStream } = useCanPlay(() => playableRom());
expect(canReachStream.value).toBe(true);
expect(canPlayStream.value).toBe(false);
});
// The desktop shell runs a locally installed emulator, so it makes a rom
// playable on its own even where nothing on the server can touch it.
it("reports native support on its own flag", () => {
@@ -29,6 +29,7 @@ import {
isPico8Rom,
isRuffleEmulationSupported,
} from "@/utils";
import { useCan } from "@/v2/composables/useCan";
export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
canPlay: ComputedRef<boolean>;
@@ -37,6 +38,7 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
canPlayPico8: ComputedRef<boolean>;
canPlayEasyRpg: ComputedRef<boolean>;
canPlayRuffle: ComputedRef<boolean>;
canReachStream: ComputedRef<boolean>;
canPlayStream: ComputedRef<boolean>;
canPlayNative: ComputedRef<boolean>;
} {
@@ -46,10 +48,12 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
const nativeStore = useNativeStore();
const { value: heartbeat } = storeToRefs(heartbeatStore);
// The emulation read grant covers every in-browser engine.
const canPlayInBrowser = useCan("rom.play");
const supportedBy = (check: typeof isEJSEmulationSupported) =>
computed(() => {
const rom = getRom();
if (!rom?.has_file_on_disk) return false;
if (!canPlayInBrowser.value || !rom?.has_file_on_disk) return false;
return check(rom.platform_slug, heartbeat.value, configStore.config);
});
@@ -75,11 +79,16 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
// The broker is handed the ROM file, so a physical game or one missing
// from the filesystem has nothing to stream any more than it has to boot.
const canPlayStream = computed(() => {
// Reaching the stream is enough to join one; starting one takes a grant.
const canReachStream = computed(() => {
const rom = getRom();
if (!rom?.has_file_on_disk) return false;
return streamingStore.containerForPlatform(rom.platform_slug) !== null;
});
const canStartStream = useCan("stream.start");
const canPlayStream = computed(
() => canReachStream.value && canStartStream.value,
);
// The shell either downloads the file or reads it off disk, so the same
// "something to boot" rule applies.
@@ -107,6 +116,7 @@ export function useCanPlay(getRom: () => SimpleRom | null | undefined): {
canPlayPico8,
canPlayEasyRpg,
canPlayRuffle,
canReachStream,
canPlayStream,
canPlayNative,
};
@@ -119,13 +119,22 @@ vi.mock("@/v2/composables/useCanPlay", () => ({
canPlayEasyRpg,
canPlayRuffle,
canPlayNative,
canPlayStream: {
canReachStream: {
get value() {
return (
Boolean(getRom()?.has_file_on_disk) && streamContainer.value !== null
);
},
},
canPlayStream: {
get value() {
return (
Boolean(getRom()?.has_file_on_disk) &&
streamContainer.value !== null &&
(grantedActions.value?.has("stream.start") ?? true)
);
},
},
}),
}));
vi.mock("@/v2/composables/useClipboard", () => ({
@@ -493,6 +502,17 @@ describe("useGameActions.play — launch confirmation", () => {
expect(actions.canPlayStream.value).toBe(false);
expect(actions.canDownload.value).toBe(false);
});
it("still offers Join without the grant to start a stream", () => {
grantedActions.value = new Set<ActionKey>(["rom.view"]);
streamContainer.value = { host: "http://stream" };
joinableSession.value = { host_username: "ada" };
const actions = useGameActions(() => romWithStatus());
expect(actions.canPlayStream.value).toBe(false);
expect(actions.canJoinStream.value).toBe(true);
expect(actions.playPath("stream")).toBeNull();
});
});
describe("useGameActions.needsLaunchConfirm", () => {
@@ -95,6 +95,7 @@ export function useGameActions(
canPlayPico8,
canPlayEasyRpg,
canPlayRuffle,
canReachStream,
canPlayStream,
canPlayNative,
} = useCanPlay(getRom);
@@ -142,7 +143,7 @@ export function useGameActions(
// and holds the answer for a freshness window, so a gallery of cards costs
// what a single card costs.
watch(
canPlayStream,
canReachStream,
(can) => {
if (can) void streamingStore.fetchJoinableSessions();
},
@@ -159,7 +160,7 @@ export function useGameActions(
});
const canJoinStream = computed(
() => canPlayStream.value && joinableSession.value !== null,
() => canReachStream.value && joinableSession.value !== null,
);
const joinHostLabel = computed(
@@ -15,6 +15,7 @@ const pico8Slugs = new Set<string>();
const easyRpgSlugs = new Set<string>();
const streamContainers = new Map<string, { label: string; emulator: string }>();
const streamingEnabled = { value: true };
const canPlayInBrowser = { value: true };
vi.mock("@/utils", () => ({
isEJSEmulationSupported: (slug: string) => ejsSlugs.has(slug),
@@ -69,6 +70,9 @@ vi.mock("pinia", async (importOriginal) => {
const actual = await importOriginal<typeof import("pinia")>();
return { ...actual, storeToRefs: (store: unknown) => store };
});
vi.mock("@/v2/composables/useCan", () => ({
useCan: () => canPlayInBrowser,
}));
vi.mock("@/locales", () => ({
default: {
global: {
@@ -88,6 +92,7 @@ beforeEach(() => {
easyRpgSlugs.clear();
streamContainers.clear();
streamingEnabled.value = true;
canPlayInBrowser.value = true;
});
describe("usePlatformPlayable", () => {
@@ -145,6 +150,19 @@ describe("usePlatformPlayable", () => {
expect(streamLabel.value).toBeNull();
expect(mode.value).toBeNull();
});
it("drops the browser engine without the in-browser play grant", () => {
canPlayInBrowser.value = false;
ejsSlugs.add("psx");
streamContainers.set("psx", {
label: "DuckStation",
emulator: "duckstation",
});
const { mode, emulator, playable } = usePlatformPlayable(() => "psx");
expect(emulator.value).toBeNull();
expect(playable.value).toBe(false);
expect(mode.value).toBe("stream");
});
});
describe("usePlatformPlayableChecker", () => {
@@ -186,6 +204,14 @@ describe("usePlatformPlayableChecker", () => {
const { isStreamable } = usePlatformPlayableChecker();
expect(isStreamable.value("ps2")).toBe(false);
});
it("reports nothing playable without the in-browser play grant", () => {
canPlayInBrowser.value = false;
ejsSlugs.add("snes");
const { isPlayable, getEmulator } = usePlatformPlayableChecker();
expect(isPlayable.value("snes")).toBe(false);
expect(getEmulator.value("snes")).toBeNull();
});
});
describe("playTooltip", () => {
@@ -12,7 +12,8 @@
// platform: "ruffle" for Flash, "jsdos" for Windows 3.x/9x, "dosbox" when
// the EJS catalogue picks the dosbox_pure core (DOS is wrapped by EJS but
// distinctive enough to surface by name in the UI), "emulatorjs" for
// everything else playable, and `null` when nothing on the server can run it.
// everything else playable, and `null` when nothing on the server can run it
// or the user lacks the in-browser play grant.
//
// `mode` folds the in-browser answer together with streaming: a platform
// served by a configured streaming container is playable too, just not in
@@ -34,6 +35,7 @@ import {
isRuffleEmulationSupported,
resolvePlatformSlug,
} from "@/utils";
import { useCan } from "@/v2/composables/useCan";
export type PlatformEmulator =
"emulatorjs" | "ruffle" | "jsdos" | "pico8" | "easyrpg" | "dosbox" | null;
@@ -77,8 +79,11 @@ export function usePlatformPlayable(getSlug: () => string | null | undefined): {
const streamingStore = useStreamingStore();
const { value: heartbeat } = storeToRefs(heartbeatStore);
const canPlayInBrowser = useCan("rom.play");
const emulator = computed<PlatformEmulator>(() =>
resolveEmulator(getSlug(), heartbeat.value, configStore.config),
canPlayInBrowser.value
? resolveEmulator(getSlug(), heartbeat.value, configStore.config)
: null,
);
const playable = computed(() => emulator.value !== null);
@@ -119,11 +124,13 @@ export function usePlatformPlayableChecker(): {
// Expose computed functions so callers that consume them inside another
// computed (sort comparator, bucket discriminator) re-run when the
// heartbeat or admin-toggle state changes.
const canPlayInBrowser = useCan("rom.play");
const getEmulator = computed(() => {
const hb = heartbeat.value;
const cfg = configStore.config;
const allowed = canPlayInBrowser.value;
return (slug: string | null | undefined): PlatformEmulator =>
resolveEmulator(slug, hb, cfg);
allowed ? resolveEmulator(slug, hb, cfg) : null;
});
const isPlayable = computed(() => {
+39 -18
View File
@@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => ({
setVolume: vi.fn(),
container: null as Record<string, unknown> | null,
capabilities: {} as Record<string, unknown>,
// Reactive once the store mock loads, so a test can turn streaming off.
streamingOff: { value: false },
presenceTick: null as (() => Promise<void>) | null,
playSession: { start: vi.fn(), flush: vi.fn() },
socketHandlers: {} as Record<string, (payload: unknown) => unknown>,
@@ -78,24 +80,30 @@ vi.mock("@/v2/stores/galleryRoms", () => ({
default: () => ({ getRomById: () => null }),
}));
vi.mock("@/stores/streaming", () => ({
useStreamingStore: () => ({
claimSession: mocks.claimSession,
containerForPlatform: () => mocks.container,
emulatorLabel: (emulator: string) =>
emulator === "duckstation" ? "DuckStation" : emulator,
platformCapabilities: () => mocks.capabilities,
fetchConfig: mocks.fetchConfig,
fetchSessionStatus: mocks.fetchSessionStatus,
forgetJoinableSession: vi.fn(),
heartbeatSession: mocks.heartbeatSession,
joinSession: mocks.joinSession,
releaseSession: mocks.releaseSession,
releaseSessionKeepalive: mocks.releaseSessionKeepalive,
saveAndExit: mocks.saveAndExit,
saveAndExitKeepalive: mocks.saveAndExitKeepalive,
}),
}));
vi.mock("@/stores/streaming", async () => {
const { ref } = await import("vue");
mocks.streamingOff = ref(false);
return {
useStreamingStore: () => ({
claimSession: mocks.claimSession,
containerForPlatform: () =>
mocks.streamingOff.value ? null : mocks.container,
containerCapabilities: (container: unknown) =>
container ? mocks.capabilities : {},
emulatorLabel: (emulator: string) =>
emulator === "duckstation" ? "DuckStation" : emulator,
fetchConfig: mocks.fetchConfig,
fetchSessionStatus: mocks.fetchSessionStatus,
forgetJoinableSession: vi.fn(),
heartbeatSession: mocks.heartbeatSession,
joinSession: mocks.joinSession,
releaseSession: mocks.releaseSession,
releaseSessionKeepalive: mocks.releaseSessionKeepalive,
saveAndExit: mocks.saveAndExit,
saveAndExitKeepalive: mocks.saveAndExitKeepalive,
}),
};
});
vi.mock("@/v2/composables/useActivityPresence", () => ({
useActivityPresence: (_rom: unknown, tick: () => Promise<void>) => {
@@ -980,6 +988,7 @@ describe("Stream state controls", () => {
afterEach(() => {
mocks.capabilities = {};
mocks.streamingOff.value = false;
});
async function barIcons(liveStates: boolean): Promise<unknown[]> {
@@ -996,6 +1005,18 @@ describe("Stream state controls", () => {
expect(icons).toContain("mdi-restore");
});
it("keeps the controls when streaming is revoked mid-game", async () => {
const wrapper = await launch({ picker: false });
await vmOf(wrapper).onPlay();
await launchReady();
mocks.streamingOff.value = true;
await flushPromises();
const icons = wrapper.findAllComponents(RBtn).map((b) => b.props("icon"));
expect(icons).toContain("mdi-content-save-outline");
expect(icons).toContain("mdi-restore");
});
it("offers neither where the emulator writes its state only on exit", async () => {
// DuckStation and RPCS3 keep an autosave slot for their state library,
// so that slot alone must not bring the buttons back.
+14 -5
View File
@@ -35,6 +35,7 @@ import {
onBeforeUnmount,
onMounted,
ref,
shallowRef,
watch,
} from "vue";
import { useI18n } from "vue-i18n";
@@ -50,6 +51,7 @@ import streamingApi, {
type LaunchReady,
type MemoryCardImport,
type MemoryCardImportDetail,
type StreamingContainer,
} from "@/services/api/streaming";
import storeAuth from "@/stores/auth";
import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms";
@@ -239,14 +241,19 @@ watch(playerState, (state) =>
setBgArt(state === "playing" ? null : bgCoverUrl.value),
);
const container = computed(() =>
rom.value
? streamingStore.containerForPlatform(rom.value.platform_slug)
: null,
// Pinned at claim, so a permission refresh that turns streaming off mid-game
// leaves the session its controls until it ends.
const heldContainer = shallowRef<StreamingContainer | null>(null);
const container = computed(
() =>
heldContainer.value ??
(rom.value
? streamingStore.containerForPlatform(rom.value.platform_slug)
: null),
);
const capabilities = computed(() =>
streamingStore.platformCapabilities(rom.value?.platform_slug),
streamingStore.containerCapabilities(container.value),
);
// Emulators that keep saves in the emulated filesystem rather than in
@@ -729,6 +736,7 @@ const claimedAt = ref<string | null>(null);
function forgetClaim(): void {
holdsClaim.value = false;
heldContainer.value = null;
claimedContainer.value = null;
claimedAt.value = null;
}
@@ -957,6 +965,7 @@ async function onPlay(cardImport?: MemoryCardImport): Promise<void> {
multiplayerOnPlay.value,
);
claimedContainer.value = launching.container;
heldContainer.value = container.value;
claimedAt.value = launching.claimed_at;
holdsClaim.value = true;
// Every exit path ran before there was a claim to hand back.