-
-
Notifications
You must be signed in to change notification settings - Fork 0
QBTCP for implementers
QBTCP is the Quiz Bowl Tournament Control Protocol. It is an application-layer protocol over HTTP with JSON bodies. It connects an electronic scoresheet to tournament control software.
This page is a guide. The normative document is
docs/QBTCP.md. That document keeps the
RFC key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY. Implement from that document, not from
this page.
QBTCP is not a transport protocol. It does not replace TCP/IP and it does not sit under TCP/IP. The name says what the protocol controls, which is a quiz bowl tournament. QBTCP runs over ordinary HTTP. An implementation opens no socket of its own. It defines no framing, no retransmission, and no congestion control.
QBTCP is also not the API of one product. QBSheet is one scoresheet. Fruity is one tournament control implementation. Neither owns the protocol. You can implement QBTCP without a read of either codebase.
QBJ is the game data and the tournament data.
QBTCP is the live conversation around that data.
QBTCP owns behaviour that exists only while a tournament runs:
- Discovery and version negotiation
- Room pairing and authentication
- Delivery of the current assignment
- Presence
- Writer ownership and takeover
- In-progress snapshots
- Final result delivery
- Reconnection and server-assisted recovery
- Help requests
- The assignment lifecycle and the revision of a round
QBTCP defines none of these:
-
Tournament,Phase,Round,Registration,Team,Player -
Match,MatchTeam,MatchPlayer,MatchQuestion ScoringRules- Any result statistic
Those belong to QBJ. When QBTCP must carry one of them, it carries a QBJ document and says so. A parallel team schema or a parallel match schema misreads the specification.
| Term | Meaning |
|---|---|
| Tournament control | The software that owns the schedule and collects the results |
| Scoresheet | The client that scores one game at a time |
| Room | A scoring position in the tournament. The room pairs and authenticates. |
| Session | The work of one scoresheet on one assigned game |
| Assignment | The game that a room must score now, as a QBJ document |
| Round revision | An integer that names which issue of a round's pairings an assignment came from |
| Active writer | The one device that can write to a session now |
The version is one integer in the path: /qbtcp/v1/....
Raise the integer only for a change that a version-1 client cannot tolerate. A new optional response field, a new optional request field, and a new capability are not such changes. A client must ignore a response field that it does not know.
Version negotiation is discovery, not a handshake. A client reads the discovery document, then decides what it can do. There is no upgrade inside the connection.
GET /qbtcp/v1
No credential. The response looks like this:
{
"protocol": "QBTCP",
"version": 1,
"capabilities": ["pairing", "assignment", "progress", "result", "recovery", "help", "presence"],
"qbj_version": "2.1.1",
"name": "Greenwood Fall Invitational"
}capabilities is the authoritative statement of server support. A client must not read support from
the absence of an error. A client must not need a capability that discovery did not advertise.
Discovery must not reveal the schedule, the room list, the team list, or any pairing code.
QBTCP uses capability tokens. A token is an opaque string. It grants exactly one scope. There are two scopes.
| Token | Scope | Header |
|---|---|---|
| A room token | One room: read the assignment, post presence and help, open a session | x-yf-room-token |
| A session token | One session: read it, write snapshots, submit the result, read recovery | x-yf-session-token |
There is no user account, no password, and no server-wide read. A session token reaches one session. A change to the identifier in the path does not reach the game of another room.
Two more headers give information only. They never authorise anything.
| Header | Meaning |
|---|---|
x-yf-device-id |
An opaque per-browser identity, used to arbitrate the writer role |
x-yf-operator-name |
The name of the scorekeeper, for the presence view of the director |
The x-yf- prefix comes from the implementation that existed before the protocol had a name. It is
historical. It is not a claim of ownership. Treat every header name as an opaque string. Do not
rename a wire field for tidiness, because a deployed client reads it.
Send every credential as a header. A credential must never appear in a URL, in a query string, in a log line, in the user interface, in an error message, or in a QBJ document.
POST /qbtcp/v1/pair
{ "code": "48213906", "roomId": "room-204" }
This endpoint exchanges a short human code for a room token. roomId is optional. It only
disambiguates a code that is not unique.
{ "roomId": "room-204", "roomName": "Room 204", "token": "<opaque room token>" }Pairing turns a memorable secret into a capability, so it is the endpoint that an attacker will choose. A server must do these three things:
- Rate-limit attempts per source, then answer
429. - Return an identical failure for a malformed code, an unknown code, a disabled room, and a mismatch. A distinguishable error lets a stranger enumerate the rooms.
- Keep the code out of every response and every log.
A server can also offer a room list for a picker:
GET /qbtcp/v1/rooms
The list gives identifiers, display names, and descriptions only. It must not give a token or a pairing code.
GET /qbtcp/v1/assignment (a room token)
The response body is a QBJ document, with the media type
application/vnd.quizbowl.qbj+json.
This is the central commitment of version 1. The assignment over the network is the same document that tournament control software could write to disk. One parser on the client reads both. There is no separate network model and no separate file model.
Operational state is not in the QBJ body, because that would need invented QBJ fields. It travels in a sibling endpoint:
GET /qbtcp/v1/assignment/status (a room token)
{
"state": "assigned",
"blocked_reason": null,
"blocked_message": null,
"session": { "session_id": "...", "resumable": true },
"previous": { "label": "Round 3 · Ninety Six vs Emerald" },
"next": { "label": "Round 5 · Clinton vs Greenwood" },
"released_round": 4,
"hold_new_starts": false
}state is assigned, none, blocked, or held. When the state is not assigned, the assignment
endpoint answers 204 No Content. It does not answer with an empty QBJ document.
POST /qbtcp/v1/sessions (a room token)
{ "match_id": "sm-4471", "device_id": "..." }
{ "session_id": "sess-9f13", "token": "<opaque session token>", "writer": true }When a session for that assignment is already open, the server returns the open one. It does not create a second one. Two devices on one game happens in a real tournament: a Chromebook dies and a phone takes over. Writer ownership resolves it. A refusal does not.
GET /qbtcp/v1/presence (a room token)
POST /qbtcp/v1/presence (a room token)
POST records that this device and this operator are alive. GET returns what the server believes
about the room.
Presence is advisory. A lost heartbeat must not end a session, invalidate a token, or change the scoring.
One session has one writer. Another device with the same session token can read. A write from that
device receives 409:
{ "error": "Another device is scoring this game.", "writer_device": "…", "can_take_over": true }A takeover is explicit and a person starts it:
POST /qbtcp/v1/sessions/{id}/writer (a session token)
{ "device_id": "...", "take_over": true }
The server moves the writer role. The old writer learns of the loss at its next write, and it must tell its operator. It must not discard the work.
A client must not take over automatically after a failed write. Automatic takeover between two live devices makes both of them believe that they are authoritative.
PUT /qbtcp/v1/sessions/{id}/progress (a session token, the writer)
The body is the current game state as a QBJ match document.
Progress is a snapshot, not a delta. Each write replaces the last one. A client that was offline sends its current state. It does not replay what it missed.
{ "sequence": 41, "match": { ... } }
sequence is a client-assigned integer that only rises inside one session. A server that receives a
lower sequence than the one it holds must discard the body and answer 200. The client is late, not
wrong, and an error would only cause a pointless retry.
A client must collapse a queue of snapshots to the newest one. A stale queued snapshot must never overwrite a newer accepted one.
Delivery is best-effort by design. A failed snapshot must not block the scoring, must not appear as a scoring error, and must not discard local state.
POST /qbtcp/v1/sessions/{id}/result (a session token, the writer)
The body is the completed game as a QBJ document, with the same media type. It is the same document that the scoresheet would download, with nothing added and nothing removed.
{ "accepted": true, "match_id": "sm-4471", "fingerprint": "…", "duplicate": false }duplicate: true means that this exact statistical result is already on record. It is the correct
answer to a retry. It is not an error. Result submission must be idempotent on the pair of Match.id
and the fingerprint.
Acceptance is not permission for the scoresheet to delete its local copy. It is also not permission to stop the manual download.
GET /qbtcp/v1/sessions/{id}/recovery (a session token)
This is the second recovery source. It serves a device whose own local copy is gone or unreadable. It returns the private state that the server received. The session capability authorises the read. There is no room-wide read and no server-wide read.
Server recovery is a fallback. The local journal of the scoresheet is the authoritative exact path.
GET /qbtcp/v1/help (a room token)
POST /qbtcp/v1/help (a room token)
DELETE /qbtcp/v1/help/{id} (a room token)
A room asks for a person: a protest to judge, an absent player, a broken buzzer. The categories are implementation-defined and discovery advertises them.
Help is orthogonal to scoring. It must not change the state of a session.
An error is JSON with a human-safe error string. A client can show that string as it is.
{ "error": "This browser origin is not approved.", "code": "origin_not_allowed" }| Status | Meaning | What the client must do |
|---|---|---|
400 |
A malformed request | Do not retry the same body |
401 |
A missing or invalid credential | Pair again. Keep the game in progress. |
403 |
A valid credential without permission, and a disallowed origin | Show it. Do not retry in a loop. |
404 |
No such endpoint, session, or room | Do not retry |
405 |
The method is not allowed | A programming error |
409 |
A writer conflict, or a conflict with a recorded result | A person must resolve it |
410 |
A newer revision superseded the assignment | Fetch the new assignment. Keep the scored work. |
413 |
The body is too large | Do not retry the same body |
429 |
Rate limited | Back off |
5xx |
A server fault | Retry with a backoff |
401 and 410 matter most. Both describe a server that changed its mind about a room in the middle
of a game. Neither one can destroy that game. The scoresheet must continue to score locally. The
result must stay available for download.
A scoresheet can lose the network at any moment. It must not lose the game. An implementation that claims conformance must hold to all of these:
- It writes the normalised assignment locally before any scoring depends on a later call.
- It makes a locally accepted scoring event durable before it delivers the event anywhere.
- A lost server never unmounts, blocks, or resets the scorer.
-
401,403, and410do not destroy the game. - A change of tournament identity does not destroy the game.
- A reload restores from local state, not from the network.
- Snapshots retry and coalesce. The current state always beats a stale queued one.
- A connected final still offers a manual QBJ download.
A scoresheet is usually a static site on a public address. Tournament control software usually runs on a laptop on the same local network. That is a cross-origin request to a private address, and both halves need work.
A server must do all of these:
- Keep a configurable allowlist of scoresheet origins.
- Echo the one approved origin in
Access-Control-Allow-Origin, and sendVary: Origin. - Never send
Access-Control-Allow-Origin: *on an authenticated endpoint. A wildcard on a capability-token API lets any page on the internet drive a tournament from the browser of a scorekeeper. - Answer the preflight
OPTIONSrequest for every path, and allow the credential headers. - Reject a preflight from an origin outside the allowlist with
403.
For Local Network Access in Chrome, a server on a private address must answer a preflight that
carries Access-Control-Request-Private-Network: true with
Access-Control-Allow-Private-Network: true. The server must also list
Access-Control-Request-Private-Network among its allowed headers. Without this, a scoresheet on a
public address cannot reach the server at all.
A client with a service worker must not cache a QBTCP response as authoritative data. A cached assignment is worse than an error, because it is a confident wrong answer.
- Capability tokens, not identities. A token says what it can do, not who holds it.
- Least scope. A room token cannot read another room. A session token cannot read another session.
- A credential never enters a QBJ document. Files travel by memory stick and by email.
- A credential never enters a log or the user interface, and that includes error text.
- A pairing failure looks the same for every cause.
- An origin allowlist, never a wildcard.
- Bounded input. Limit the body size and the URL length. Treat all received JSON as untrusted.
| Concern | Owner |
|---|---|
| What a match, team, player, round, or scoring rule is | QBJ |
| What the result of a game was | QBJ |
| Which game a room must score now | QBTCP, which delivers a QBJ document |
| Whether the room can score it, and who writes | QBTCP |
| How a result returns, and whether it is a duplicate | QBTCP |
If a change needs a field that describes the game, that field belongs in QBJ or in the _qbtcp
extension. It does not belong in the protocol.
QBSheet · AGPL-3.0-or-later · The specifications in
docs/ are normative. This wiki is a guide.
Report a security problem with
SECURITY.md. Do not open a public issue
for a security problem.