diff --git a/livekit/test_voice_limit_guard.py b/livekit/test_voice_limit_guard.py index af60862..9ff765f 100644 --- a/livekit/test_voice_limit_guard.py +++ b/livekit/test_voice_limit_guard.py @@ -362,3 +362,30 @@ class TestRoomStateParsing(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class NormalizePermissionTest(unittest.TestCase): + """livekit-server's JSON uses proto names (snake_case); the reconciler must + not read camelCase and conclude nobody publishes (which silently disabled + the live screenshare kill).""" + + def test_snake_case_permission_is_reconciled(self): + calls = [] + guard.livekit_update_participant = lambda alias, identity, perm: calls.append((identity, perm)) + participant = { + "identity": "@a:x:DEV", + "permission": {"can_subscribe": True, "can_publish": True, "can_publish_data": True, "can_publish_sources": []}, + } + changed = guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE", "SCREEN_SHARE_AUDIO"}) + self.assertTrue(changed) + identity, perm = calls[0] + self.assertEqual(identity, "@a:x:DEV") + self.assertTrue(perm["canPublish"]) + self.assertNotIn("SCREEN_SHARE", perm["canPublishSources"]) + self.assertIn("MICROPHONE", perm["canPublishSources"]) + self.assertTrue(perm["canSubscribe"]) # preserved + + def test_camel_case_still_works(self): + guard.livekit_update_participant = lambda *a: None + participant = {"identity": "@a:x:DEV", "permission": {"canPublish": True, "canPublishSources": ["CAMERA"]}} + self.assertFalse(guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE"})) diff --git a/livekit/voice-limit-guard.py b/livekit/voice-limit-guard.py index 15e52a4..51326a4 100644 --- a/livekit/voice-limit-guard.py +++ b/livekit/voice-limit-guard.py @@ -399,10 +399,27 @@ def reconcile_publish_sources(current, forbidden: set): return sorted(effective - forbidden) +def _camel(key: str) -> str: + head, *rest = key.split("_") + return head + "".join(part.capitalize() for part in rest) + + +def normalize_permission(perm: dict) -> dict: + """LiveKit's Twirp JSON serialises ParticipantPermission with proto field + names (`can_publish`, `can_publish_sources`, ...) — verified against + livekit-server 1.13 — while the JWT grant and older docs use camelCase. + Return a camelCase copy so the policy code reads one shape. (protojson + accepts either spelling on input, so the copy we send back is fine.)""" + out = {} + for key, value in (perm or {}).items(): + out[_camel(key) if "_" in key else key] = value + return out + + def reconcile_participant(alias: str, participant: dict, forbidden: set) -> bool: """Enforce the forbidden-source policy on one live participant. Returns True if an UpdateParticipant call was issued.""" - perm = participant.get("permission") or {} + perm = normalize_permission(participant.get("permission") or {}) if not perm.get("canPublish", False): return False # publishes nothing -> nothing to revoke current = perm.get("canPublishSources") or []