Skip to content

Public Spectating (anonymous-spectating)

Status: shipped (Phase 1 merged + smoked; Phase 2 + falsification fixes in PR #87). Follow-on: spectate-sse-scale.

Logged-out visitors can watch the lobby and any table felt with no account and no wallet, while every fund-affecting action stays behind sign-in.

The reframe

The request was "make auth required for fund actions so that spectating can be public." An enforcement audit found the framing inverted: auth was already enforced on every fund path (sit-down requires a verified JWT — the legacy no-JWT enter path is gone; deposit/withdraw/faucet/custody are JWT-or-service-key on both banks; table-create requires a JWT; wallet-linking is JWT-gated). The bff-and-auth-hardening work had closed the last gaps.

The actual blocker was the opposite: the whole poker app was walled behind the Privy login gate (PUBLIC_PATHS empty in apps/poker/app/providers.tsx), so a logged-out visitor never reached the felt — even though the spectate SSE gateway was already anonymous-capable (owner-private-stream). So this is a frontend gate + affordance change, not a security-architecture change.

What shipped

  • isPublicPath() (apps/poker/lib/public-paths.ts) — the public route set is the lobby (/) + a table felt (/table/<id>) only; every user-keyed page (/agents, /claim, /faucet, /history, /profile, /exchange) stays gated (nothing to show anonymously).
  • Anonymous-aware providersPrivyAuthGate gains an allowAnonymous prop. On a public path a signed-out visitor renders the page read-only; on a non-public path (or a mid-auth need_invite/error state) the full-screen login card shows. A signed-in visitor keeps the normal ready → children + UnlinkedWalletGuard path everywhere. The tree is always wrapped in the Privy bridge + a global login modal.
  • Global login modal (apps/poker/src/shared/components/LoginModalProvider.tsx, useLoginModal) — reuses the extracted LoginCardBody (one login surface, two frames), emits ui.auth.login_modal.open (with a reason per triggering affordance), auto-closes on sign-in.
  • Affordance-gate — every fund affordance on a public page opens the modal or is absent: seat-click (handleSeatClickOrLogin), Create table/tournament, Agent Builder, tournament "Sign in to register"; the header fund controls (connect-wallet, cage, faucet, coffee) are hidden for signed-out; the dev/operator + seat bot / Add Bot buttons are gated to signed-in.
  • Transport is unchangedselectTransport already returns observer-SSE for a null token; the gateway stream is already public-only (no hole cards / reasoning).

Invariants

  • E-SPEC-1 — anonymous access widens nothing on the money path (audit-proven: no public-page affordance can fire an unauthed fund request).
  • E-SPEC-2 — the login modal is the only crossing from watching → playing; no gated affordance is a silent no-op or a raw unauthed call.

Falsification fixes (the load-bearing part)

Both from one root cause — making a gated surface public re-exposes every affordance the page-level auth wall was hiding:

  • A11 — the classic-UI (?ui=classic) + seat bot button OR'd a stale SSE-rails gameState.devSeatTestBots field with the authToken-aware prop, leaking the unauthed demo button to signed-out visitors. Fixed with a named gate canSeatBotButton (apps/poker/src/poker/lib/seat-bot-gate.ts) that reads the authToken-aware signal alone.
  • A12PrivyAuthGate.allowAnonymous rendered children for every non-ready status, so a first-timer who OAuth'd from a public-page modal but had no account yet (need_invite) landed on the anonymous page with no invite prompt — the launch signup path was stranded. Fixed by excluding need_invite/error from the anonymous render.

Deliberately descoped

  • The allowAnonymousSpectators kill switch + per-IP anon rate-limit tuning — not needed at launch scale (a single gateway holds cheap idle SSE connections; the pain threshold is ~10k+ concurrent or an abusive IP, orders of magnitude beyond ~100 invited people; the gateway already has basic bounds; the whole feature reverts by un-merging). The real scaling lever — a cached-snapshot fan-out for passive spectating (esp. the frontpage demo, off the existing retained …:public:snapshot Redis key) — is the spectate-sse-scale follow-on plan.

References

  • Full ADR-equivalent + impl: .indusk/planning/archive/anonymous-spectating/
  • Inherited decisions: owner-private-stream (gateway anonymous-capable), observer-architecture (the SSE/stream split), bff-and-auth-hardening (fund-path auth).
  • Follow-on: .indusk/planning/spectate-sse-scale/