Appearance
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 providers —
PrivyAuthGategains anallowAnonymousprop. On a public path a signed-out visitor renders the page read-only; on a non-public path (or a mid-authneed_invite/errorstate) the full-screen login card shows. A signed-in visitor keeps the normalready → children + UnlinkedWalletGuardpath 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 extractedLoginCardBody(one login surface, two frames), emitsui.auth.login_modal.open(with areasonper 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 Botbuttons are gated to signed-in. - Transport is unchanged —
selectTransportalready 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 botbutton OR'd a stale SSE-railsgameState.devSeatTestBotsfield with the authToken-aware prop, leaking the unauthed demo button to signed-out visitors. Fixed with a named gatecanSeatBotButton(apps/poker/src/poker/lib/seat-bot-gate.ts) that reads the authToken-aware signal alone. - A12 —
PrivyAuthGate.allowAnonymousrendered 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 excludingneed_invite/errorfrom the anonymous render.
Deliberately descoped
- The
allowAnonymousSpectatorskill 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:snapshotRedis key) — is thespectate-sse-scalefollow-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/