---
title: Scope auction broadcasts by league membership
type: task
status: completed
description: >-
  Every league WebSocket broadcast is scoped by a client-supplied league_id and nothing else, so an
  anonymous or non-member socket receives a league auction board in full. Needs an operator ruling
  on whether that board is public before the fix takes shape.
base_uri: user:task/league/scope-auction-broadcasts-by-league-membership.md
created_at: '2026-09-06T18:23:26.849Z'
entity_id: fd05167d-f7c0-4feb-9365-2fc73566f34e
finished_at: '2026-09-06T19:23:49.529Z'
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[███████████████████████-███████-██████-████-████████████]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-06T19:23:49.529Z'
visibility_analyzed_at: '2026-11-05T23:50:19.798Z'
---

## Problem

Every WebSocket broadcast in the league is scoped by a **client-supplied** `league_id` and by nothing else. There is no membership check on the read side.

`api/index.mjs` handles the upgrade and sets `ws.league_id = Number(parsed.searchParams.get('league_id'))`, deliberately allowing an unauthenticated connection (`// Don't destroy the socket for invalid tokens, allow connection without auth`) because the data-views page is anonymously reachable. Both fan-outs then filter on that field alone:

- `api.locals.broadcast` (`api/index.mjs:390`) — `if (c.league_id === league_id)`
- `Auction.broadcast` (`api/sockets/auction.mjs:369`) — same comparison against `this._lid`

`api/sockets/index.mjs` returns early for an unauthenticated socket, so it registers no message handlers — but the connection stays open and stays in `wss.clients`, which is the set both broadcasters iterate.

Two consequences, and the second is the sharper one:

- An **anonymous** visitor who connects with `?league_id=N` receives every auction broadcast for league N: bids and prices, `AUCTION_SETTLEMENT_STATUS` outstanding team ids, sales, mode and timer events.
- **Authentication does not help**, because nothing checks membership — a signed-in user simply passes a different `league_id` and reads any league's board.

## Why this needs a ruling before a fix

The auction guide already argues that most of this content is not sealed: caps are roster-derived and effectively public, and second-price settlement publishes the runner-up's ceiling in the settled price itself. The 2026-09-06 operator ruling on the engine bid went further and accepted publishing the implied price. No broadcast carries an `auction_elections.maximum_bid`.

So the open question is not "is a maximum leaking" — it is not — but **whether a league's live operational board is public by design**. `AUCTION_BID` payloads also carry `user_id`. The answer decides the shape of the fix, so it is an operator decision rather than an implementation detail.

## Clean end state (pending that ruling)

Broadcast reach should be scoped by **membership**, resolved server-side, not by a field the client hands over — the same lesson `docs/guides/auction.md` § "Sealed bids are scoped by OWNERSHIP, not by authorization" already records for the standing-elections read route.

The gate belongs on the auction and scoreboard fan-outs rather than on the connection, because the anonymous data-views and plays-view sockets must keep working. Sketch: resolve the authenticated user's teams for the league at upgrade or at join, stamp a verified flag on the socket, and have `Auction.broadcast` and `api.locals.broadcast` filter on it.

- [ ] Get the operator ruling on whether a league's live auction board is public
- [ ] If it is not: scope both fan-outs by resolved membership, leaving the anonymous data-view and plays-view paths untouched
- [ ] Score it by connecting a socket with no token and a socket for a non-member user, and requiring both to receive nothing while a member's socket receives the broadcast — a fix asserted only against the member case cannot fail

## Cross-references

- `████████████████████████████████████████████████████████` (Completed 2026-09-05) closed the WRITE side of this — the socket no longer reads any identity or team from a client payload. This is the READ side, which that task did not cover.
- `docs/guides/auction.md` § "Sealed bids are scoped by OWNERSHIP, not by authorization"

## Resolution: the board is public (operator ruling, 2026-09-06)

**Closed as intended behavior, not fixed.** The operator ruled that a league's live auction board is public and that no membership scoping is wanted. Nothing in the code changed.

That is consistent with the rest of the design rather than an exception to it: caps are roster-derived and effectively public, second-price settlement publishes the runner-up's ceiling in the settled price itself, the same day's engine-bid ruling accepted publishing the implied price outright, and the league runs a public transaction record.

The ruling is recorded in `docs/guides/auction.md` § "THE BROADCAST BOARD IS PUBLIC, and that is a ruling rather than an oversight" (commit `c140c43bb`), and that placement is the whole point of this task's remaining value. The shape reads exactly like a hole — a client-supplied `league_id` as the only scope, sitting next to an explicit comment allowing unauthenticated connections — so a session finding it cold would re-derive it as a security finding and scope the fan-outs, silently breaking the anonymous data-view and plays-view sockets that share the mechanism.

**What the ruling does not cover.** It is a judgement about the board as it stands, not a licence for the socket to carry anything. No broadcast carries an `auction_elections.maximum_bid`, and that is the line the sealed-bid design rests on — which is why `GET /auction-elections` and both write verbs are narrowed to OWNERSHIP. `AUCTION_BID` payloads do carry `user_id`. A new sealed field on a public fan-out needs its own ruling.
