Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,34 @@ lands and that heading is renamed when a release is cut.

---

## [v1.2.1] - 2026-09-22

### Security

- **A game's results are only written for the people that game was sent.**
When a table is set up on FinalTable, the guest list that goes with it is
now kept beside the game, and the receiver writes bust-outs, re-entries and
finishing places only for accounts on it. Before this, a delivery naming
somebody who had never been invited would have made that account a player
in the session and given it an approved invite to the event — which is what
decides who can see a private event and its guest list. Reaching that took
being the FinalTable server the table was handed to, so nothing was open to
a member here; it is closed because the receiver should record a night, not
extend the guest list. Ids that were never sent are refused and written to
the activity log once per delivery, however many a standings list names. A
table set up before this release keeps recording as it did.

### Changed

- **Watching an online game is public, and the guide now says so.** *Hide
guest list* covers the guest list on the event page; the watch link is
pass-on-able by design — the invitation email tells every invitee as much,
and somebody on the rail sees the field by name and the chip counts, never
anyone's cards. Nothing behaves differently; the documentation stops
leaving the question open.

---

## [v1.2.0] - 2026-09-21

### Added
Expand Down
2 changes: 2 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,8 @@ A poker **tournament** can be played on FinalTable, the online table, instead of

Pressing the button makes the game, tells everyone on the guest list where to play (a notification with the join link, through their usual channels, under the *changes* category) and turns the section into the table's status: registering with its start, then the level, blinds, time to the next level, players left, and whether the host has paused. **Play at FinalTable** is the join link for people with a seat, **Watch** the rail link for anyone who can see the event; both have a copy button.

**Watching is public, on purpose.** The rail link seats nobody and anyone who has it may pass it on — the invitation email says so, and everyone on the guest list gets it there. Somebody watching sees the field by name, the chip counts and the table, the same as a player who has busted; they never see anyone's cards. *Hide guest list* covers the guest list on the event page, not the rail: if you do not want a night watched, do not hand the link out.

**Driving the table.** Managers get **Start now** (before the scheduled time; two people must be seated), **Pause** and **Resume**, **Take out of play** beside any seated player (their seat goes, they are told; during a hand it waits for the hand to end), and **Cancel the game**, which sends everyone back to the lobby and keeps the places so far. FinalTable refuses what its own host could not do, in its own words, and the page shows the sentence.

**The record of the night.** FinalTable reports back as the game runs, and this side writes what an in-person game would have written by hand: every bust-out becomes a finish position (provisional until late registration and re-entry close; the final standings are the last word), a re-entry clears it and counts as a rebuy, and the ending records every place, marks who actually played as bought in, sets the session to *finished* and computes payouts, points, bounties and entry tickets from **this event's payout structure**, exactly as Manage Game's *Finish* does. FinalTable's own prize figures are kept for the status line only. The league table picks the event up like any other. A cancelled game leaves the session as it stood. For an online game, let FinalTable keep the book: hand edits in Manage Game are overwritten by the standings when the game ends.
Expand Down
4 changes: 1 addition & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,7 @@ The biggest blocker right now is 10DLC carrier registration. This milestone is e
- Optional outbound webhooks (on new RSVP, new event, etc.)
- REST API (read-only to start)
- ✅ Online tables: a poker tournament played on a connected FinalTable server, set up from the event page, with the bust-outs and standings written back into the session (v1.2.0)
- Online tables, two follow-ups from the security review of that work (2026-09-22). Neither is a hole — both were raised, checked and ruled out, because reaching them means being the FinalTable server the admin registered and the manager handed the game to — but both are cheap and close a gap between what the code allows and what it means:
- **Bound the webhook to the roster it was sent.** `finaltable_apply_event()` takes `player.user_id` from a signed payload and, if that account has no row, creates one and an *approved* `event_invites` row beside it. Persist the roster at setup and reject an id that was not on it, logging a warning so a misbehaving app is visible. Three lines in `www/_finaltable.php`.
- **Decide whether *hide guest list* binds the watch link.** The `state` read withholds the roster and the entrants from non-managers on exactly that ground, but the rail link goes to every viewer and FinalTable's rail shows the field by name — so the setting is honoured in one path and walked past in another. Either gate the rail link the way the join link is gated, or say in DOCS.md that watching is public and the setting covers the guest list only.
- ✅ Online tables, the two follow-ups from the security review of 2026-09-22, both closed in v1.2.1: the receiver now writes results only for accounts on the roster that game was sent (kept on `finaltable_games.roster`, refusals logged once per delivery), and *hide guest list* is documented as binding the event page's guest list rather than the watch link — the rail is pass-on-able by design on both sides, and every invitee already has the link by email

---

Expand Down
20 changes: 18 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,24 @@ and accepts signed requests from it. What holds it:
the organiser's *hide guest list* setting.
- **What it writes** is bounded: `finaltable_games` columns and the
`poker_players` rows of the one session tied to that game, matched by
`user_id` (never by the name in the payload). A player it has to create
gets an invite row beside it so `sync_invitees()` cannot soft-remove it.
`user_id` (never by the name in the payload) **and only for an id on the
roster this side sent**, which is kept as JSON on `finaltable_games.roster`
when the table is set up. Without that test a signed delivery naming a
stranger would have this side make them a player and give them an approved
`event_invites` row — which is an authorisation grant, since
`event_visibility_sql()` reads it. Ids that were never sent are refused and
logged once per delivery, however many a standings list names. A game set
up before the column existed has `roster` NULL, which means "do not check",
so a table already running keeps recording; that fallback can go a release
later. A player it does have to create gets an invite row beside it so
`sync_invitees()` cannot soft-remove it.
- **The watch link is not gated, and that is the decision.** The `state` read
withholds the roster and the entrants from a non-manager, but the rail link
goes to every viewer of the event and FinalTable's rail shows the field by
name. Gating the button would not make the night private — every invitee
already has the link by email, and the link is pass-on-able by design on
both sides — so *hide guest list* binds the guest list on the event page
and nothing else. DOCS.md says so where a host will read it.
- The new `data-act` controls (`ftSetup`, `ftControl`, `ftCancel`,
`ftRemove`, `ftCopy`) live in `finaltable.js` and render only when the
event carries `online_app_id`; give a dev event one before the
Expand Down
2 changes: 1 addition & 1 deletion WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A developer-facing map of every page in the GameNight app: where it lives, who can reach it, what it does, and how users get to it. Companion to `DOCS.md` (which is the end-user/admin guide).

App version at time of writing: **1.2.0**
App version at time of writing: **1.2.1**
All page files live in `www/`. Data endpoints (`*_dl.php`) are listed but not detailed here — they exist only to serve AJAX from the page that owns them.

---
Expand Down
33 changes: 32 additions & 1 deletion www/_finaltable.php
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,29 @@ function finaltable_apply_event(PDO $db, array $game, string $event, array $d):
foreach ($keys as $k) if (array_key_exists($k, $d)) $o[$k] = $d[$k];
return $o;
};
$playerOf = function (array $p) use ($db, $sid, $eid): ?array {
// Who this game was sent. A delivery is signed with that game's own
// secret, so it comes from the server the table was handed to - but a
// result for somebody who was never on the guest list is not a result,
// and acting on one would make that account a player here and give it an
// approved invite to the event. NULL means a game set up before the
// column existed: do not check, or a table already running stops
// recording. FinalTable has no way to amend a guest list after creation,
// so this list and its roster cannot drift apart.
$roster = null;
if (isset($game['roster']) && $game['roster'] !== null && $game['roster'] !== '') {
$decoded = json_decode((string)$game['roster'], true);
if (is_array($decoded)) $roster = array_map('intval', $decoded);
}
$refused = [];
$playerOf = function (array $p) use ($db, $sid, $eid, $roster, &$refused): ?array {
$uid = (int)($p['user_id'] ?? 0);
if ($uid <= 0 || !empty($p['is_bot']) || $sid <= 0) return null;
if ($roster !== null && !in_array($uid, $roster, true)) {
// Collected rather than logged one by one: a crafted completion
// carries a whole standings list, and that is one event, not ten.
if (!in_array($uid, $refused, true)) $refused[] = $uid;
return null;
}
return finaltable_player_row($db, $sid, $eid, $uid, isset($p['name']) ? (string)$p['name'] : null);
};

Expand Down Expand Up @@ -502,4 +522,15 @@ function finaltable_apply_event(PDO $db, array $game, string $event, array $d):
default:
break; // an event this side does not know: acknowledged, ignored
}

// One line however many names were refused, so a misbehaving app is
// visible without a crafted completion being able to fill the log.
if ($refused) {
db_log_anon_activity(
'finaltable webhook: ' . $event . ' for game=' . (string)$game['game_id']
. ' named ' . count($refused) . ' account(s) not on its roster (#'
. implode(', #', array_slice($refused, 0, 10)) . ')',
'warning'
);
}
}
7 changes: 7 additions & 0 deletions www/db.php
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,13 @@ function db_init(PDO $pdo): void {
FOREIGN KEY (app_id) REFERENCES sso_apps(id)
)"); } catch (Exception $e) {}
try { $pdo->exec("CREATE INDEX IF NOT EXISTS idx_ft_games_game ON finaltable_games(game_id)"); } catch (Exception $e) {}
// Who was sent to that game, as a JSON list of account ids. The receiver
// writes results for these people and nobody else: a signed delivery
// naming an id that never went would otherwise have this side make the
// account a player, and an approved invite beside it. NULL on a game set
// up before this column existed, which means "do not check" - a table
// already running must keep recording its results.
try { $pdo->exec("ALTER TABLE finaltable_games ADD COLUMN roster TEXT"); } catch (Exception $e) {}
// Every delivery FinalTable has made, keyed (game, delivery id). The INSERT
// is the lock: a UNIQUE failure is a retry of one already handled, answered
// 200 and dropped. Heartbeats carry delivery_id NULL and never land here.
Expand Down
7 changes: 5 additions & 2 deletions www/finaltable_dl.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,16 @@ function ft_fail(string $msg, int $code = 200): void {

$db->prepare('DELETE FROM finaltable_games WHERE event_id = ?')->execute([$event_id]);
$db->prepare("INSERT INTO finaltable_games
(event_id, app_id, game_id, code, rail, join_url, rail_url, webhook_secret, status, last_read, last_read_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'registering', ?, ?, ?)")
(event_id, app_id, game_id, code, rail, join_url, rail_url, webhook_secret, status, last_read, last_read_at, created_by, roster)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'registering', ?, ?, ?, ?)")
->execute([
$event_id, (int)$app['id'], (string)$g['id'],
$g['code'] ?? null, $g['rail'] ?? null,
$g['links']['join'] ?? null, $g['links']['rail'] ?? null,
encrypt_value($secret), json_encode($g), gmdate('Y-m-d H:i:s'), $uid,
// The guest list as sent. FinalTable has no way to amend it after
// creation, so this is what its webhooks may name and nothing else.
json_encode(array_map(fn($i) => (int)$i['user_id'], $pre['roster']['invitees'])),
]);
$row = finaltable_game_for_event($db, $event_id);

Expand Down
2 changes: 1 addition & 1 deletion www/version.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?php
define('APP_VERSION', '1.2.0');
define('APP_VERSION', '1.2.1');

// Source for the "update available" check (public repo, no auth needed).
// run_update_check() in db.php fetches this, regexes out APP_VERSION, and
Expand Down
Loading