Files
station-master/docs/plans/jitsi-common-board.md
T
Jesse.MarkowitzandClaude Opus 5 312e0301e0 v0.7.9.8 — the test command did not typecheck, and the plan had gone stale
Housekeeping before v0.8.0: the answer to "anything else that should be
looked at first". One real hole, one stale document, and my own leavings.

#102 — `npm test` passed green on a type error. `pretest` ran
`scripts/build-web.ts`, which invokes `tsc --ignoreConfig` against three
web entry points, so it saw only what those three transitively import and
under a WEAKER configuration than tsconfig.json — no
`noUncheckedIndexedAccess`, no `exactOptionalPropertyTypes`,
`--types ''`. It never saw `src/server/` or a single file under `test/`.

Demonstrated rather than argued: a planted
`const DELIBERATE_TYPE_ERROR: number = 'not a number';` in
src/server/session.ts gives `npm run typecheck` a TS2322 and `npm test` a
clean `# fail 0`. `pretest` is `tsc --noEmit && node
scripts/build-web.ts` now, and the same error exits 1 with the tests
never running.

This mattered THIS week rather than generally: v0.8.0 is steps 2-7 of the
common board — display stream, credentials, persistence, Chromium
supervisor — which is almost entirely src/server/, exactly the half the
test command could not see.

#103 — the plan had drifted from the code it is the source for.
docs/plans/jitsi-common-board.md was written 2026-08-27, still said "No
implementation has been performed", and is what steps 2-7 get built from.
Step 1 shipped across four releases since, so every "current code
finding" under it described a fault that is now fixed — a document
reading as present tense and nine days stale sends the next reader to fix
things twice.

Measured: its PublicFrame sketch lists four properties never built
(protocolVersion, config, scoring, deckCounts) and omits 28 that exist,
and the shape is the real difference — the implementation is FLAT where
the plan grouped things into objects, so a renderer written from the
sketch would not compile. The plan now says so at the top and at step 1,
names src/sim/view.ts and the redaction allow-list as the authority,
keeps the original sketch for its reasoning, and calls out
`protocolVersion` as unbuilt rather than dropping it quietly — step 2 is
the reconnecting display stream and is the first thing that would want
one.

One step-1 item is STRUCK OFF rather than built: "add the Red Flag holder
to the public player projection". The premise does not hold here.
`decks.redFlags` is written once, in setup.ts, from
`optionalRules.emergencyToolbox`, and never again — `redFlag.play` emits
`phaseEnded` and does not spend it — so every player holds one or none
does, decided before the deal. A per-player `redFlagHeld` would be one
already-public option copied N times, while telling every reader of the
common board that it varies by player and might change mid-game. Worse
than the absence. Pinned by test so it is not re-raised from the plan.

Four dead imports removed, all mine: `HAND_LIMIT` left unused in
apply.ts, view.ts and web/game.ts when 0.7.9.6 consolidated the three
copies of the §6.2 test, and `actingPlayer` in web/game.ts, dead since
0.7.9.5 made `currentActor` delegate. Finding them re-measured #46:
`tsc --noUnusedLocals` now reports 40, up from 29 on 2026-08-30. That
entry's "without the flag this list simply regrows" is a measurement
rather than a forecast now. The other 36 and the flag stay open.

946 tests pass, up from 943. No behaviour changes: three new tests pin an
invariant, and the rest is a build command, dead imports and a document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
2026-09-08 03:41:39 -04:00

979 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Station Master Jitsi Common Board Implementation Plan
**Status (2026-09-07):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.**
Step 1 landed across four releases rather than one — v0.7.9.2 (the two narration leaks), v0.7.9.4
(the projection helpers and the redaction net), v0.7.9.5 (the narration path), and v0.7.9.8 (this
reconciliation). One of its items is struck off rather than built; see § Public game projection.
**This document has drifted from the code and is no longer the authority on what exists.** It was
written on 2026-08-27 against the code of that date, and the "Current code findings" under each step
describe faults that were then real — several are now fixed, and reading them as present tense will
send you to fix things twice. Where a step is marked built, `src/sim/view.ts`, `src/server/`, and the
tests named in TODO.md are the authority. Steps 2-7 were never implemented and their findings have
NOT been re-verified against the current code; check each before building on it.
## Summary
Add a privacy-safe common game board that can be viewed in a browser and published into the game’s Jitsi meeting by a server-managed headless Chromium participant.
The publisher represents the game table, not a player or bot. Human and bot actions both update the same public board. No hands, objectives, legal moves, random seed, private draws, or other player-only state may enter the display data path.
The implementation is divided into independently useful stages:
1. Establish a secure public-state projection and close existing narration leaks.
2. Add a public display stream, credentials, persistence, and reconnect behavior.
3. Build the reusable 1280×720 common-board renderer.
4. Preserve individual human and bot actions as display animation steps.
5. Add a minimal visual-only Jitsi engine.
6. Supervise one headless Chromium process per published game.
7. Integrate lifecycle, configuration, packaging, health, and live verification.
The browser display remains useful without Jitsi. The public projection and leak fixes improve multiplayer security even if no visual display is deployed.
## Research incorporated
This plan is based on the current Station Master server, engine, multiplayer view, persistence, and untracked Jitsi harness, plus a read-only review of the sibling `jitsi-transcription` packaging repository at commit `9cae1877a9d9a9f489de8f870dad6ff011521c9d`.
The Jitsi Transcription review materially changes the earlier architecture:
- Use direct `child_process.spawn` Chromium supervision, not Playwright or CDP.
- Use one Chromium process and temporary browser profile per published game, not multiple contexts in one shared browser.
- Copy the minimal proven Jitsi/control patterns into Station Master; do not create a runtime dependency on the sibling repository.
- Treat `conference.authenticationRequired` as a retryable “waiting for moderator” state.
- Use the exact StartOS-proven Chromium flag set.
- Explicitly disable third-party requests in `JitsiMeetJS.init`.
- Gracefully leave Jitsi before terminating Chromium to reduce ghost participants.
- Package Chromium and `tini`; visual-only publishing needs neither PulseAudio nor Xvfb.
- Default to one concurrent publisher until target-hardware measurements justify more.
The sibling repository had unrelated local modifications and untracked files. They are not part of this plan.
## Public interfaces and types
### Public game projection
Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...>` because new private `Frame` fields could then leak automatically.
> **RECONCILED 2026-09-07 (v0.7.9.8).** Step 1 is BUILT, and what shipped is not shaped like the
> sketch below. This block was the design; `PublicFrame` in `src/sim/view.ts` is now the authority,
> and `test/redaction.test.ts`'s allow-list is the enumeration of it that fails when it changes.
> **Read those two, not this**, when building steps 2-7. The differences that matter:
>
> - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts`
> object. Their contents sit at the top level — `day`, `stage`, `clock` (a time string), `phase`,
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentDepth`, `salvage`,
> `yards`, `mode`, `days`, `optionalRules`, `houseRules`, `minCombinedRevenue`,
> `maxCollisionsPerDay`, `maxCollisionsTotal`, `collisionsToday`, `collisionsTotal`, `status`,
> `outcome`, `extraDays`, `extensionVotes`, `official`, `tally`, `openingRolls`, `timetable`,
> `timetableWhat`, `trains`, `players`, `division`, `districts`.
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. Step 2 is the reconnecting
> display stream, which is the first thing that would want one — decide there whether to add it,
> rather than assuming it is already on the wire.
> - **`redFlagHeld` is STRUCK OFF**, not deferred. See below.
> - **Fields gained since this was written** that the renderer should know about: `crewTrays` and
> `queued` (#98, the Crew Tray pool and the trains waiting for one), and on each district's cells
> `heldAtLimits` (#99, a train stopped on the Limit Track) and `enhancementsSpent` (#101, a
> dispatch device spent for the Day).
>
> **Why `redFlagHeld` is struck off rather than built.** The premise does not hold in this codebase.
> `decks.redFlags` is written in exactly one place — `setup.ts`, from
> `config.optionalRules.emergencyToolbox` — and never again; `redFlag.play` emits `phaseEnded` and
> does not spend it. So every player holds one or none does, decided before the deal. A per-player
> `redFlagHeld` would be `optionalRules.emergencyToolbox` copied N times, already public, while
> telling every reader of the common board that it varies by player and may change mid-game. The
> invariant is pinned by test (`test/display-gaps.test.ts`) so this does not get re-raised from the
> plan text: if the rule ever becomes per-player, that test fails.
The design as originally written, kept for the reasoning:
```ts
type PublicPlayerView = {
index: PlayerIndex;
seat: PlayerIndex;
name: string;
revenue: number;
handCount: number;
redFlagHeld: boolean;
};
type PublicDistrictView = {
seat: PlayerIndex;
player: PlayerIndex;
cells: readonly CellView[];
facilities: readonly FacilityView[];
runningRow: number;
limits: unknown;
};
type PublicFrame = {
protocolVersion: 1;
status: GameState['status'];
clock: PublicClockView;
config: PublicConfigView;
scoring: PublicScoringView;
players: readonly PublicPlayerView[];
division: readonly DivisionView[];
districts: readonly PublicDistrictView[];
deckCounts: PublicDeckCounts;
departments: PublicDepartmentsView;
salvage: PublicSalvageView;
yards: PublicYardsView;
timetable: PublicTimetableView;
};
```
Reuse existing view types only after verifying every reused field is public. Create narrower public variants where an existing type includes viewer-specific or private data.
The projection must never include:
- `viewer`
- card identities in any player’s hand
- `justDrawn`
- objectives
- decisions or private prompts
- menus, actions, legal moves, or blocked reasons
- seed or RNG state
- raw `GameState`
- intent history
- private event details
- unresolved internal ordering data
- full shared narration logs
### Display stream
```ts
type PublicFrameDelta = PartialPublicFrameDelta;
type DisplayReset = {
type: 'display.reset';
seq: number;
frame: PublicFrame;
};
type DisplayStep = {
type: 'display.step';
seq: number;
actor: {
player: PlayerIndex;
seat: PlayerIndex;
source: 'human' | 'bot';
};
frame: PublicFrameDelta;
lines: readonly PublicNarrationLine[];
};
type DisplayMessage = DisplayReset | DisplayStep;
```
A new or reconnected display client always receives `display.reset`. Subsequent accepted intents produce ordered `display.step` messages. The server does not replay missed steps; reconnecting clients reset to the latest public state.
### Jitsi publisher state
```ts
type JitsiPublisherState =
| 'disabled'
| 'queued'
| 'launching'
| 'connecting'
| 'waiting-for-admission'
| 'waiting-for-moderator'
| 'publishing'
| 'reconnecting'
| 'stopping'
| 'failed';
```
Expose sanitized state, last transition time, and a safe error summary through the existing session/health surfaces. Never expose meeting credentials, display credentials, Jitsi tokens, XMPP service credentials, or browser launch URLs.
## Step 1 — Secure public-state projection
> **BUILT — v0.7.9.2 through v0.7.9.5.** Everything in "Required changes" below shipped except the
> Red Flag holder, which is struck off (§ Public game projection). Mapping to the work items in
> TODO.md: the projection helpers and `currentActorOfState` are **#95**, the systematic redaction
> net and its allow-list are **#91**, the seed and blind-draw narration leaks are **#92**, and
> "stop passing the full game log into `frameFor()`" is **#97** — which also fixed a reconnect bug
> the duplicate had been masking, so read that entry before touching narration in step 2.
>
> **The findings below are as of 2026-08-27 and are now HISTORY, not a task list.** Two are worth
> carrying forward anyway: districts must be keyed by SEAT with ownership resolved through
> `playerAtSeat` (Employee Rotation), and the public view must be composed UPWARD from shared
> helpers, never by calling the player `snapshot()` once per seat. Both are load-bearing for step 3.
>
> One finding was struck off on measurement rather than fixed: it warns that a display reading
> `clock.currentActor` could highlight the wrong district during a decision. Across six seeds and
> 3,600 decision points that field and `actingPlayer` never disagreed. `currentActorOfState` exists
> anyway as the one place to ask — and **#96** later found a real instance of the same class in a
> state nobody had checked, the §3.3 vote, where the game is not `active` at all.
### Current code findings
`src/sim/view.ts` currently builds `Frame` for one real viewer and defaults that viewer to player zero. It combines shared table state, one district, and viewer-private fields. Calling it for a spectator would silently expose player zero’s district and private information.
`src/server/session.ts` calls:
```ts
snapshot(game.state, game.log, ..., seat)
```
This embeds the complete shared narration log in every `Frame`, while `Push.lines` also sends narration incrementally.
Existing narration has at least two privacy leaks:
- `newMultiplayerGame()` places the seed in the shared log.
- A blind Home Office draw can resolve the drawn card to its real name in shared narration.
Existing redaction tests pass `[]` for narration and mainly search internal IDs, so they do not detect resolved card names or seed text in the full serialized payload.
`currentActor(game)` accounts for Superintendent/pending decisions, while `snapshot()` reads `state.clock.currentActor`. A public display using the latter could highlight the wrong district.
Employee Rotation means district ownership cannot be assumed to match player index. Districts must be keyed by seat, with current ownership resolved through `playerAtSeat`.
### Required changes
Create explicit projection helpers in the simulation/view layer:
- `projectDistrict(state, seat)`
- `projectDivision(state)`
- `projectSharedTable(state)`
- `publicSnapshot(state)`
- `currentActorOfState(state)`
Refactor the player snapshot and public snapshot to share only safe lower-level projection helpers. Do not implement the public view by repeatedly calling the player `snapshot()` function.
Make the existing actor logic use the same engine-level helper so player views, bot execution, and the common board agree during decisions and Superintendent actions.
Add the Red Flag holder to the public player projection. It is public game state but is currently absent from `Frame`.
Remove seed narration from multiplayer game creation. Retain the seed only in persistence and administrative/replay data.
Change blind-draw narration to a generic public line such as “Home Office drew a card.” Keep the card identity available only to the drawing player through the existing owner-only `justDrawn` mechanism.
Stop passing the full game log into `frameFor()`. Continue sending sanitized incremental narration through `Push.lines`.
### Tests
Add tests that serialize the entire public frame and player pushes, then search for:
- every opponent hand card ID
- every opponent hand card display name
- current objective IDs and names
- `justDrawn` for the wrong player
- seed values and seed narration
- private decision/menu/action data
Cover:
- a newly created multiplayer game
- a blind Home Office draw
- a pending decision
- Superintendent acting
- Employee Rotation before and after ownership changes
- reconnect pushes
- a finished game
Acceptance requires an allow-list review of every `PublicFrame` property. Passing redaction tests alone is insufficient.
## Step 2 — Display credentials, stream, and persistence
### Display metadata
Create separate per-game display metadata rather than extending the authoritative game save:
```ts
type SavedDisplayMetadata = {
schemaVersion: 1;
room: string;
viewToken: string;
publisherToken: string;
nextSequence: number;
createdAt: number;
};
```
Store it in the same per-game data directory as `display.json`, using the persistence layer’s existing atomic-write pattern.
Generate:
- a cryptographically random Jitsi room name
- a high-entropy `viewToken`
- a separate high-entropy `publisherToken`
Do not derive any token from game ID, room name, player token, seed, or timestamp.
For older saves without `display.json`, generate the metadata once on resume and persist it. Failure to initialize display metadata must disable display/Jitsi for that game without preventing the underlying game from resuming.
### HTTP endpoints
Add:
- `GET /display.html#token=<viewToken>` — manual common-board page
- `GET /api/display/stream?token=<viewToken>` — public-board SSE
- `GET /display-agent.html` — internal headless publisher page
- WebSocket upgrade at `/api/display/control` — supervisor/agent control
Extend the authenticated game/session response with:
- display URL
- Jitsi meeting URL
- sanitized publisher state
The browser display reads the fragment token, removes it from the visible address if practical, and supplies it to the SSE request. Fragments keep the credential out of the initial HTTP request and normal server access logs.
Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over the authenticated control WebSocket.
### SSE behavior
Maintain a display subscriber set per game.
On connection:
1. Authenticate `viewToken`.
2. Produce the latest `PublicFrame`.
3. Send `display.reset` with the current sequence.
4. Continue existing heartbeat behavior.
5. Send later `display.step` messages in sequence order.
Use a dedicated public-frame delta function rather than the player `Frame` delta. The observed public snapshot grows enough during longer games that full frames for every action would be wasteful.
If an SSE client is slow or disconnected, close it and let EventSource reconnect to a new reset. Do not keep an unbounded replay buffer.
### Security requirements
- Compare tokens without placing them in error messages.
- Do not log query strings or control registration messages containing tokens.
- Never accept game intents through display endpoints.
- Do not let a view token register as a publisher.
- Apply payload and connection limits independently from player SSE.
- Keep display failure isolated from player pushes and game persistence.
### Tests
Add HTTP tests after refactoring `startServer()` to return the server/listening handle needed by tests.
Cover:
- valid and invalid view tokens
- publisher token rejected as a view token
- reset on first connect and reconnect
- monotonically increasing sequence IDs
- public deltas reconstruct the same frame as a fresh reset
- heartbeat behavior
- no private fields in raw SSE bytes
- legacy save migration
- atomic `display.json` persistence
- display failure not interrupting `/api/intent` or player SSE
## Step 3 — Reusable common-board renderer
### Renderer structure
Build one renderer shared by:
- the manual browser display
- the headless Jitsi publisher page
Use a fixed 1280×720 canvas at 10 frames per second. Set the captured video track’s `contentHint` to `"detail"`.
Keep the canvas palette stable. The Jitsi Transcription prototype deliberately changed color themes to prove updates were arriving; that behavior strobes during frequent game actions and must not enter the product.
Separate the renderer into:
- a pure layout/view-model stage
- image/SVG preparation
- canvas drawing
- animation queue management
### Layout
Use this fixed layout:
- Header: game name/status, day/stage/phase, current actor, Red Flag.
- Main left: persistent Division board.
- Main right: focused district.
- Footer/side panels: player standings, hand counts, timetable, public decks/resources, and recent narration.
- Compact district summaries: every district’s owner, revenue, and operational status.
The focused district is:
1. the acting player’s current seat;
2. otherwise the most recent actor’s seat;
3. otherwise seat zero.
Resolve owner from seat on every frame so Employee Rotation updates the labels without moving the district itself.
The complete `PublicFrame` carries all public districts even though the 720p layout focuses one at a time.
### Existing assets
Reuse the existing Division and office SVG generators and exported board CSS. Adapt their APIs so the Division roster can be rendered without a private viewer.
Render SVG output into canvas-safe images. Cache images by serialized SVG/content key and invalidate only when the corresponding public model changes.
Do not recreate game rules or labels inside the renderer. The projection layer supplies display-ready public labels.
### Display behavior
- Human and bot actions use the same animation path.
- Normal display-step duration: 700 ms.
- If the queue exceeds 12 steps, use 200 ms catch-up transitions.
- A reset clears queued animations and renders immediately.
- Rendering or asset failure keeps the previous good frame and reports a sanitized diagnostic.
- Recent narration is bounded and uses only sanitized `DisplayStep.lines`.
- No server-side sleeps are permitted.
Update `scripts/build-web.ts` to explicitly build/copy the new display entry points, HTML pages, styles, and Jitsi vendor asset. The current static build manually lists its artifacts, so relying on automatic discovery will omit the pages.
### Tests
Test the pure layout model for:
- focused-district selection
- Employee Rotation ownership
- Superintendent decisions
- two through four players
- empty and dense boards
- long player/facility names
- finished-game state
Test renderer lifecycle with a fake canvas/image layer:
- reset clears the queue
- step order is preserved
- catch-up speed activates at the threshold
- stopped renderers stop timers and media tracks
- no private input type is accepted
Perform visual review at 1280×720 and as a reduced Jitsi tile. Text and train positions must remain legible without opening a tooltip.
## Step 4 — Preserve individual human and bot actions
### Current code findings
`GameSession.intent()` applies the human intent, runs `driveBots()`, and only then creates player pushes.
`driveBots()` can call `submit()` many times. Player deltas intentionally collapse those moves into one final state, which is appropriate for gameplay but would make bots appear to teleport through several actions on the common board.
Engine events cannot be replayed into display state. One accepted intent may run automatic `drain()` work and emit several events, and the event list is not a complete reducer.
### Required changes
Introduce a display-step collector inside `GameSession`.
After every successful `submit()`:
1. Resolve the acting player and current seat.
2. Produce the sanitized narration lines added by that submission.
3. Create the next `PublicFrame` immediately.
4. Delta it against the last emitted public frame.
5. Assign the next display sequence.
6. Emit one `DisplayStep`.
Apply this both to:
- the human submission in `intent()`
- every successful bot submission inside `driveBots()`
Capture the frame immediately. Do not retain a mutable `GameState` reference for later projection because every retained reference would otherwise resolve to the final state.
One display step corresponds to one accepted intent, including automatic consequences drained by that intent. Do not create one step per low-level `GameEvent`.
Keep player pushes unchanged: players still receive the final coalesced result after all immediately due bots finish.
Opening bot moves that occur before any client connects do not need replay. Persist the resulting game state and sequence; a later display receives the final reset.
### Failure isolation
Display projection or broadcasting must not invalidate an already accepted game move.
If step generation fails:
- record a sanitized server diagnostic
- mark the publisher/display state degraded
- send a fresh reset on the next successful display update
- continue normal game and player push processing
### Tests
Cover:
- one human intent with no bot response
- one human intent followed by several bot intents
- consecutive bot turns
- bot pending decisions
- automatic engine work within one intent
- ordering of narration and frames
- sequence continuity
- player pushes remaining coalesced
- reconstructed display state matching `publicSnapshot()` after the final step
## Step 5 — Minimal visual-only Jitsi engine
### Source strategy
Port the smallest relevant production patterns from `jitsi-transcription` into Station Master with attribution where required. Do not import the sibling repository at runtime, add it as a submodule, or copy its transcription/audio/chat features.
Retain only:
- Jitsi configuration parsing
- connection/conference lifecycle
- lobby handling
- reconnect classification/backoff
- generated-display track creation
- generated-display cleanup and republishing
- normalized state/error events
- minimal control protocol
Pin the known working `lib-jitsi-meet` release:
```text
v2192.0.0+d6f3312f
```
Add `ws` for the Node control broker. Do not add Playwright.
### Agent page
`display-agent.html` must:
1. Validate its opaque session ID and publisher token from the URL fragment.
2. Connect to `/api/display/control`.
3. Register as the engine for exactly one session.
4. Wait for a supervisor command before joining Jitsi.
5. Connect to the public display SSE using the supplied view token.
6. Render the same common-board canvas as the manual page.
7. Capture the canvas stream at 10 fps.
8. Join Jitsi and publish it as a desktop video track.
9. Report normalized lifecycle state over the control channel.
10. Leave and stop all tracks on supervisor command or `pagehide`.
Meeting server, room, XMPP configuration, and view token are delivered over the control WebSocket, not placed in query parameters.
### Jitsi publishing
Use:
```ts
canvas.captureStream(10)
JitsiMeetJS.createLocalTracksFromMediaStreams([{
mediaType: 'video',
sourceType: 'generated',
stream,
track: stream.getVideoTracks()[0],
videoType: 'desktop'
}])
```
Require exactly one generated video track. Dispose any unexpected auxiliary tracks.
Wait for the first video frame before publishing and report a diagnostic if it does not arrive. Do not automatically recycle a healthy track merely because a remote participant initially sees a blank publication; the existing harness observed occasional first-publication blankness on the Jitsi side.
On reconnect:
- retain the program-owned canvas stream
- remove/dispose the stale Jitsi local track
- reconnect and rejoin
- create a fresh Jitsi wrapper track around a clone of the retained stream
- republish it
### Jitsi initialization and privacy
Initialize with:
```ts
JitsiMeetJS.init({
disableAudioLevels: true,
enableAnalyticsLogging: false,
disableThirdPartyRequests: true
});
```
The pinned library declarations confirm `disableThirdPartyRequests` is supported.
Do not initialize microphones, cameras, remote audio sinks, chat, TTS, STT, analytics, or rtcstats endpoints.
Before release, capture browser network destinations during a live session and verify that traffic is limited to:
- Station Master loopback/server endpoints
- the configured Jitsi deployment and its advertised media infrastructure
Unexpected telemetry destinations fail acceptance.
### Meeting states
Handle these explicitly:
- `waiting-for-admission`: the participant joined a lobby and is waiting for a moderator.
- `waiting-for-moderator`: the guest cannot create the room because no authenticated moderator has opened it.
- `reconnecting`: a previously joined publisher lost its connection and is retrying.
- `failed`: malformed configuration, exhausted retry budget, unrecoverable Jitsi error, or repeated browser failure.
`conference.authenticationRequired` may arrive asynchronously as a conference error after the initial join command appears successful. Handle both synchronous and event paths.
For waiting-for-moderator:
- leave/disconnect the failed attempt
- retry every 8 seconds
- stop after `JITSI_WAIT_FOR_MODERATOR_SECONDS`, default 600
- immediately continue when a human moderator opens the meeting
For lobby admission, remain connected until admitted, stopped, or the configured join deadline expires.
### Tests
Port/adapt the sibling repository’s proven tests for:
- generated-display track contract
- lifecycle transitions
- lobby state
- asynchronous `authenticationRequired`
- reconnect classification
- generated-display republish
- cleanup after publish failure
- stale callbacks from an old connection
- initialization privacy flags
- no audio/camera track creation
## Step 6 — Chromium publisher supervisor
### Process model
Create one Chromium child process per published game.
Do not share one Chromium process across games. Per-game processes provide crash isolation, simple lifecycle ownership, and match the production-proven Jitsi Transcription design.
Default publisher capacity to one:
```text
JITSI_MAX_PUBLISHERS=1
```
Games beyond capacity enter `queued` in creation order. Make the cap configurable only after measuring CPU and memory on target Station Master hardware.
### Browser launch
Find Chromium using:
1. `CHROME_BIN`
2. `/usr/bin/chromium`
3. `/usr/bin/chromium-browser`
4. `/usr/bin/google-chrome`
Use a unique temporary user-data directory per publisher and remove it after exit.
Launch with the StartOS-proven flags:
```text
--headless=new
--no-sandbox
--disable-dev-shm-usage
--autoplay-policy=no-user-gesture-required
--use-fake-device-for-media-stream
--use-fake-ui-for-media-stream
--disable-background-timer-throttling
--disable-renderer-backgrounding
--disable-backgrounding-occluded-windows
```
`--no-sandbox` is acceptable only inside the existing StartOS container boundary and because Chromium loads the Station Master-owned loopback agent page.
Filter noisy Chromium stderr, but retain messages matching fatal conditions, renderer crashes, out-of-memory errors, and “Aw, Snap”.
### Control protocol
Use a versioned, session-keyed JSON protocol over `/api/display/control`.
Registration includes:
- protocol version
- role `engine`
- session ID
- publisher token
The broker must:
- validate every message
- limit payloads to 64 KiB
- disable per-message compression
- route commands/events only to the registered session
- allow a newer engine to supersede only the same session
- reject client/engine role changes on an established socket
- clear pending commands when an engine disconnects
- reconnect the agent-side WebSocket every two seconds until stopped
Public frames remain on SSE and never traverse this control protocol.
### Lifecycle and recovery
Supervisor flow:
1. Allocate capacity.
2. Spawn Chromium.
3. Wait for authenticated engine registration.
4. Send Jitsi join configuration.
5. Wait for joined/admitted state.
6. Command generated-display publication.
7. Monitor process, control socket, and Jitsi state.
8. Gracefully stop on game completion, server shutdown, or administrative stop.
For unexpected Chromium exit during an active game:
- mark `reconnecting`
- clean the old profile
- relaunch with delays of 1, 2, 4, 8, and 16 seconds
- reset the consecutive-failure count after five stable minutes
- enter `failed` after five consecutive launch/registration failures
- keep the underlying game and browser display operational
### Graceful teardown
On stop:
1. Send the engine a leave command.
2. Wait up to five seconds for confirmation.
3. Send Chromium `SIGTERM`.
4. Wait up to three additional seconds.
5. Use `SIGKILL` only if it remains alive.
6. Remove the temporary profile.
7. Release publisher capacity.
Clean leave matters because Jitsi may show a stale participant for 30–120 seconds after abrupt termination. The board does not consume remote audio, so these ghosts are cosmetic, but duplicate meeting participants remain undesirable.
Publish the final game state for a five-minute completion grace period, then leave the meeting. Server shutdown bypasses this grace period and leaves immediately.
### Tests
Use fake child processes and fake control sockets to test:
- exact required Chromium flags
- binary discovery
- profile isolation and cleanup
- capacity queue ordering
- engine registration authentication
- per-session supersession
- command timeout and disconnection
- crash relaunch backoff
- retry exhaustion
- graceful leave before termination
- forced kill fallback
- publisher failure not affecting the game session
## Step 7 — Configuration, lifecycle, packaging, and observability
### Configuration
Support:
```text
JITSI_SERVER_URL
JITSI_GUEST_DOMAIN
JITSI_SERVICE_URL
JITSI_XMPP_DOMAIN
JITSI_MUC_DOMAIN
JITSI_FORCE_JVB=false
JITSI_MAX_PUBLISHERS=1
JITSI_WAIT_FOR_MODERATOR_SECONDS=600
JITSI_JOIN_TIMEOUT_SECONDS=600
JITSI_FINISHED_GRACE_SECONDS=300
CHROME_BIN
```
Require `JITSI_SERVER_URL` to be an HTTP(S) origin without credentials, path, query, or fragment.
Default the XMPP WebSocket to:
```text
wss://<jitsi-host>/xmpp-websocket
```
Default MUC to `conference.<xmpp-domain>`. Keep explicit overrides for self-hosted deployments.
If `JITSI_SERVER_URL` is absent:
- browser common-board display remains enabled
- Jitsi meeting URL and publisher are disabled
- game creation and play remain unaffected
JWT/authenticated publisher support is out of scope for the first version. Authenticated-room deployments rely on a human moderator opening the room and the publisher’s waiting-for-moderator retry.
### Session integration
Create display metadata when the lobby starts a game.
Return the meeting and display links to every authenticated player. All players receive the same links.
On server startup:
- load active game saves
- load or migrate display metadata
- rebuild current public snapshots
- queue publishers only for active games
- do not automatically republish completed games
On SIGTERM, including StartOS backup shutdown:
- stop accepting new publisher work
- command every engine to leave
- terminate browsers
- then close HTTP/control services
### Health and logs
Health must distinguish:
- game server availability
- common display availability
- Jitsi configuration present
- Chromium available
- publishers active/queued/failed
Do not report Jitsi publishing as healthy merely because the supervisor is running.
Use structured logs containing:
- game/session ID
- publisher state transition
- safe Jitsi room identifier
- browser exit code/signal
- retry attempt
- sanitized error category
Exclude all tokens, full control frames, player private state, query strings, and browser profile paths.
### Dependencies and packaging
Add runtime dependencies:
- the pinned `lib-jitsi-meet` release
- `ws`
Add Chromium and `tini` to the Station Master runtime image or companion StartOS packaging repository.
Use `tini` as PID 1 so terminated Chromium children are reaped.
Visual-only Station Master publishing does not require:
- PulseAudio
- Xvfb
- xauth
- ffmpeg
- Playwright browsers
- CDP tooling
Run the service as a non-root application user. Chromium still receives `--no-sandbox` because the StartOS subcontainer does not grant the kernel capabilities required by Chromium’s internal sandbox.
Do not raise the package RAM requirement based solely on the Jitsi Transcription measurements. Measure the combined Station Master server plus publisher on actual target hardware first.
## End-to-end test plan
### Automated tests
Run:
- Station Master typecheck
- all existing engine/server/web tests
- public-projection and leak tests
- display delta/reconstruction tests
- HTTP/SSE authentication tests
- renderer lifecycle tests
- control-protocol tests
- Jitsi adapter tests with a fake runtime
- Chromium supervisor tests
- persistence migration tests
### Local integration
With a local or test Jitsi deployment:
1. Start a two-player game with one bot.
2. Open the manual display and both player screens.
3. Confirm human moves update all three.
4. Confirm bot moves appear as distinct ordered animations.
5. Join the Jitsi meeting from another browser.
6. Confirm the Station Master board is published as a desktop share.
7. Force an XMPP/media disconnect.
8. Confirm reconnect and display-track republish.
9. Stop the game/server and confirm the publisher leaves.
### Authenticated deployment
On a Jitsi deployment where guests cannot create rooms:
1. Start the Station Master game before opening the meeting.
2. Confirm publisher state becomes `waiting-for-moderator`.
3. Open the meeting as a moderator.
4. Confirm the publisher joins on the next retry.
5. If lobby is enabled, confirm `waiting-for-admission` until admitted.
6. Confirm neither state is reported as an immediate terminal failure.
### Privacy verification
Capture:
- raw display SSE
- rendered canvas screenshots
- control WebSocket messages
- Jitsi network destinations
- server logs
Verify that none contains:
- card identities from hands
- objectives
- player-only prompts or moves
- private draw identities
- seed
- view/publisher tokens in logs
- unintended third-party telemetry
### Soak and target hardware
Run at least a two-hour Station Master integration soak with:
- continual public-board updates
- human and bot moves
- two forced connection drops
- one forced Chromium termination
- clean service shutdown
Record:
- container RSS
- Chromium RSS
- CPU during idle and animation bursts
- Jitsi reconnect time
- display queue depth
- dropped SSE clients
- stale meeting-participant duration after graceful and forced exits
The sibling repository’s four-hour run validates the underlying headless Jitsi approach, but Station Master still needs this integration-specific measurement.
## Acceptance criteria
The feature is complete when:
- Every player can open one shared browser display.
- The same display is visible as a desktop share in the game’s Jitsi meeting.
- Human and bot moves update that board through the same path.
- Consecutive bot intents remain visibly distinct.
- A reconnecting display reconstructs the latest state without replay.
- Jitsi disconnection republishes the existing canvas stream.
- A guest publisher waits for a moderator rather than failing immediately.
- Browser/Jitsi failure never prevents normal game play.
- Shutdown leaves the conference before Chromium termination.
- No private game information appears in projection, transport, rendering, logs, or Jitsi.
- No unexpected third-party telemetry connection is observed.
- Chromium resource use is measured on target hardware before increasing concurrency.
## Assumptions and defaults
- The common board is a separate participant named `Station Master — Common Board`.
- It represents the table, never a particular human or bot.
- Jitsi is configurable and self-hosted; meet.jit.si-specific behavior is not assumed.
- One publisher process is allowed by default.
- A human moderator may need to open or admit the publisher.
- Public display and normal multiplayer remain valuable and operational without Jitsi.
- The sibling Jitsi Transcription repository is a reference implementation, not a shared runtime package.
- The untracked Station Master harness remains research material and is not promoted wholesale into production.
- Audio, transcription, speech, chat, camera capture, player screen sharing, and JWT publisher authentication are out of scope.