# Fix WS Control-Permission Bypass for Guests

Priority: **High** · Effort: **Trivial (one location)** · Risk: **Low**

## Problem

Guests are *intended* to be unable to control playback (`routes/helpers.ts:30`:

```ts
if (user.is_guest && permission === "control") return false;
```

) but the **WebSocket message handler** does not route through `userHasPermission`. It re-implements the check inline and **omits the guest guard**:

```ts
// websocket.ts:87-93
const canControl = user.is_admin
  || config.defaultPermissions?.includes("control")
  || hasPermission(userId, "channel", ws.data.channelId, "control");
if (!canControl) { ... return; }
```

Because the default config ships `defaultPermissions: ["listen", "control"]` (`config.json:5-8`), **any guest can `pause`, `unpause`, `seek`, and `jump` on any channel**, affecting all listeners.

The HTTP control endpoints (`routes/channels.ts:176, 196, 216, 261`) correctly use `userHasPermission`, so this is an inconsistency between the two code paths that reach the same `Channel` mutators.

## Affected Location

- `websocket.ts:86-93` — inline permission check, guest-unaware.

## Root Cause

Duplicated permission logic in two places. The WS handler was written before / diverged from `helpers.userHasPermission`.

## Implementation Plan

### Step 1 — Import `userHasPermission`

At the top of `websocket.ts`:

```ts
import { userHasPermission } from "./routes/helpers";
```

### Step 2 — Replace the inline check

Replace `websocket.ts:86-93` with:

```ts
if (!userHasPermission(user, "channel", ws.data.channelId, "control")) {
  console.log("[WS] User lacks control permission:", user.username, "(guest=" + user.is_guest + ")");
  return;
}
```

`userHasPermission` already encapsulates: admin bypass, the guest `control` denial (`helpers.ts:30`), the `defaultPermissions` config check, and the DB permission lookup. Routing through it makes WS and HTTP behavior identical.

### Step 3 — Verify no other call sites rely on the old behavior

`grep` the repo for the inline pattern (`config.defaultPermissions?.includes("control")`) — it should now appear only in `helpers.ts:33` (the canonical check). Any other duplicate should be replaced the same way.

## Validation

- **Guest cannot control**: sign in as a guest (or hit the server unauthenticated with `allowGuests: true`), open a channel WS, send `{"action":"pause"}`. Confirm the channel does **not** pause and the server logs the "lacks control permission" line.
- **Non-guest user still controls**: sign in as `test`/`testuser`, send the same action, confirm the channel pauses.
- **Admin controls**: sign in as admin, confirm control works on channels the admin does not own.
- **Guest still receives state**: confirm the guest client still gets `track`/state broadcasts (i.e. the *listen* path is unaffected — only `control` is denied).
- **Config matrix**: temporarily set `defaultPermissions` to `["listen"]` only and confirm a *non-guest* non-admin user is now also denied control via WS (matches the HTTP path).

## Risk / Rollback

- The only behavioral change is denying control to guests (and, if `defaultPermissions` lacks `"control"`, to non-admin users) — which is the documented intent. If any deployment currently relies on guests controlling playback, that was an unintentional privilege and should be re-granted explicitly via the permissions table, not by reverting.
- No schema or DB change.
- Rollback = revert the single edit.
