RFC-120: Login-first access with a logged-out lure landing¶
- Status: Draft
- Authors: Marko, Claude (Opus 4.8)
- Stakeholders: Consumer App (learning-player), Server API, Infra/Edge (Caddy)
- Related RFCs:
docs/rfc/RFC-108-operator-public-gated-surface.md(reuses the player pre-launch gate pattern)- Related UX specs:
docs/uxs/UXS-012-consumer-home.md— Home is now authenticated-only; the logged-out lure landing (LandingView,/welcome) is specified there under "Access model".- Related Documents:
infra/caddy/player.caddy(the coming-soon gate; issue#1262)web/learning-player/docs/capacitor-build-runbook.md(mobile build/ship)
Abstract¶
- What: Move the learning-player from "fully anonymous-browsable app behind a coming-soon curtain" to a login-first model: a logged-out visitor sees a lure landing (a curated teaser of the corpus with all actions removed and a "create a free account" CTA); all real content and actions require a free account. Web and mobile behave identically.
- Why: Convert visitors into free accounts ("without an account, nothing is there"), and give the native TestFlight build a clean auth model that bakes no shared secret — each user's own login is the key.
- Approach: A dedicated logged-out landing view + login-first route guard (frontend), a curated anonymous teaser surface (backend/edge), and one additive edge rule so a logged-in client (native) reaches the API via its own Bearer with no baked cookie.
Architecture Alignment: Keeps the existing HMAC session model (app_sessions), the OAuth
bypass (@oauth_public), and the layered gate; it adds a login-first UX layer on top rather than
replacing auth.
Problem Statement¶
Today the app is not login-first. HomeView renders for anonymous visitors and fetches
/discover + /corpus/trending-topics on mount; the router only gates /queue, /library,
/profile; and dozens of /api/app/* read endpoints serve content to anonymous users by
design (get_optional_user): /episodes, /episodes/:slug*, /search, /discover, /trending,
/topics/:id, /persons/:id, /podcasts, /storylines, /themes, etc. The only thing
hiding this pre-launch is the Caddy coming-soon curtain (#1262) — a shared secret
(cl_preview cookie / marko+guest basic-auth).
Two problems:
- Product: we want a freemium funnel — a public taste that entices sign-up, with real content/actions behind a free account. The current "anonymous sees everything" defeats that.
- Mobile secret: to ship a TestFlight build that works while pre-launch, the current design
bakes the shared
cl_previewcookie into the IPA (extractable; breaks on rotation). We want the native build to carry no shared secret — the tester's own login should be the key.
Use Cases: - Invited beta user (web) → passes the invited-only gate → sees the lure landing → creates a free account → full app. - TestFlight tester (mobile) → opens app → lure landing (or sign-in) → signs in with their own Google account → full app. Nothing secret shipped in the app. - Public launch → drop the invited-only gate; the lure landing becomes the public front door; account signup flips from allowlist to open.
Goals¶
- Logged-out users (web + mobile) get a lure landing: curated teaser content, no actions (play/save/queue/follow), a prominent "create a free account" CTA.
- All real content + actions require a free account — enforced consistently across web and mobile.
- The native build bakes no shared secret; a logged-in client reaches the API with its own Bearer.
- Preserve the invited-only posture during beta (the layered model below), and make public launch a small, well-defined flip.
- Keep web's existing basic-auth doorman working (operator explicit requirement).
Non-Goals: - Editorial hand-curation of the teaser (v1 reuses an existing recency/trending feed). - Paid tiers / entitlements (this is free-account-only). - Dropping the coming-soon gate now (that is the public-launch step, out of scope here).
Constraints & Assumptions¶
- Layered auth model (decided): during beta, the Caddy invited-only gate stays as the outer layer; the lure landing + free-account content live behind it; the gate is dropped at public launch. (Operator decision, this session.)
- Account creation is backend-gated by
APP_SIGNUP_MODE(defaultallowlist) +PLAYER_ALLOWED_EMAILS. Beta = allowlist; public launch =open. - Native auth already works (RFC-108 /
#1310):ASWebAuthenticationSession(iOS) /@capacitor/browser(Android) → signed token in@capacitor/preferences→Authorization: Bearer. - Browsers never attach
Authorizationto top-level navigations, so an additive "Bearer passes" edge rule does not open the web coming-soon document — it only helps the native client.
Design & Implementation¶
1. Frontend — lure landing + login-first (web + mobile)¶
- New
LandingView(route/for logged-out; or a/welcomethe guard redirects to). Shape DECIDED = "marketing lure" (slim), not a mirror ofHomeView: - Value-prop hero ("Understand any podcast in minutes…") + primary "Create your free account" CTA + secondary "Sign in".
- ONE curated "Featured this week" rail — ~6–8 episode cards, read-only (no play/save/queue/follow).
- A row of topic chips ("Explore: #ai #markets …") — read-only.
- A short "How it works" 3-step strip.
- Repeat "Create your free account" CTA at the foot. Every card/interaction funnels to signup — clicking a card does NOT open the (auth-gated) detail. This keeps the anonymous surface to the small teaser set (§3) and optimizes first-impression/conversion.
- Login-first guard: flip the router so all content routes require auth (remove the per-route
requiresAuthexceptions; keep/login,/landing, and static/legal public). Logged-out →LandingView. The existing?redirect=<path>post-login return is already wired. HomeViewbecomes authenticated-only (its logged-out branches are removed; the lure lives inLandingView).
2. Teaser surface (curated anonymous content)¶
- v1: reuse
GET /discover(already anonymous, recency-ordered) and/orGET /corpus/trending-topicsas the teaser feed, capped to a small N and stripped of anything requiring auth. - The teaser is the only anonymous content surface. Its endpoint(s) are explicitly enumerated so the edge/backend can treat them as the public lure and everything else as account-only.
3. Enforcement depth (DECIDED: backend-enforce now)¶
Enforce login-first at both the frontend (router/UI) and the backend now. Correction from
review (B2): most content endpoints today have no auth dependency at all (not merely
get_optional_user) — e.g. every GET in app_episodes.py (/episodes, /podcasts,
/podcasts/{id}/episodes|signals, /episodes/{slug} + /related|/insights|/stats|/entities|/segments|/audio-source|/search),
/themes, /storylines (app_discover.py), /entities/search, /topics/{id}/perspectives
(app_relational.py). So the sweep is "add an auth dependency to ~30 endpoints that never had
one," not "swap get_optional_user → get_current_user" (which is only ~12 call sites).
This must be mechanical, not by-hand: build an anonymous-surface inventory — every @router
GET/POST vs. its Depends — and an explicit allow-list of anonymous survivors checked in as the
single source of truth for both backend and Caddy. Everything not on the allow-list gets
Depends(get_current_user).
Anonymous survivors allow-list (the ONLY anonymous surface):
- GET /discover — featured-episode taste (server-clamped, below).
- GET /corpus/trending-topics — topic chips (server-clamped).
- GET /artwork — teaser card images (else the landing renders broken images — B3). Path-validated
via safe_artwork_target; accept that refs are enumerable from teaser payloads.
- GET/POST /comms/unsubscribe — token-based email unsubscribe (must stay anonymous — I6).
- /auth/*, /.well-known/*, MCP OAuth, health — already gate-exempt (@oauth_public).
Server-side anonymous clamp (I1): /discover (max limit 50) and /corpus/trending-topics
(max 100) must force a small cap for anonymous callers in the endpoint (e.g. anon → 8, ignore
the query param). The cap cannot live in the client — gate-exempt means the whole internet can call
these pre-launch. State explicitly: pre-launch we accept leaking ~8 recent episode
titles/slugs/artwork + top topic labels as the intended public lure.
Telemetry (I4): POST /graph-events and POST /discover/click are anonymous today. The landing
must suppress them when logged-out (not gate-exempt them — an anonymous world-writable endpoint
is an abuse surface).
Everything else requires auth, so a logged-out user clicking a teaser card is funnelled to signup.
4. Edge — no baked secret for mobile¶
Add one additive rule to player.caddy, above @authed. Path-scoped to /api/app/* (B2) so a
Bearer header does NOT bypass the coming-soon document curtain — only API calls (which is all
the native app needs; native prod loads a local bundle):
# A logged-in client (native app) reaches the API with its own Bearer — no shared
# cl_preview cookie. Path-scoped so the web document stays curtained; backend still
# verifies the token + PLAYER_ALLOWED_EMAILS.
@bearer {
header Authorization Bearer*
path /api/app/*
}
handle @bearer { reverse_proxy 127.0.0.1:8092 }
Teaser exemption uses exact paths (not a glob — a glob like /api/app/discover* would also
exempt POST /discover/click, M1):
@teaser path /api/app/discover /api/app/corpus/trending-topics /api/app/artwork
handle @teaser { reverse_proxy 127.0.0.1:8092 }
Order invariant (tested in the curl matrix): @teaser and @bearer sit above @authed (which
today 401-challenges a native Bearer via basic_auth).
Keep @preview / @preview_ok / @authed / @oauth_public / @static / coming-soon fallback
unchanged — web is untouched. The native build drops VITE_PREVIEW_COOKIE +
VITE_PREVIEW_BASIC_AUTH (no initGateCookie, no Basic fallback).
Teaser is gate-exempt (DECIDED, D4). The two teaser endpoints (§3) are added to the Caddy
bypass (alongside @static / @oauth_public), so the logged-out landing renders on both web
and mobile with no secret. Everything else stays gated (web) / auth-required (backend).
Key Decisions¶
- KD1 — Layered gate, not replaced. Basic-auth invited-only stays for beta; login-first + lure sit behind it. Dropping the gate is the public-launch flip, not part of this RFC.
- KD2 — Reuse
/discoveras the teaser (v1). No new curated endpoint until there's a reason; avoids editorial infra now. - KD3 — No baked secret on mobile. The native build ships zero gate credential; the tester's
own Bearer is the key (edge
@bearer).
Alternatives Considered¶
- Bake the shared
cl_previewcookie in the IPA (status quo). Rejected: extractable secret; a gate rotation bricks every installed build (documented failure mode). - Client-signal bypass for all native traffic (
Origin: capacitor://localhost). Softer curtain (spoofable); considered only for the teaser surface (D4), not the whole API. -
Frontend-only enforcement (Caddy gate protects the API). Rejected as the primary model — it's a lie the curtain hides, and it would not survive the public-launch gate drop. Backend enforcement is in scope now (D1).
-
Enforcement-completeness gate (the release gate): a
curlmatrix asserting every/api/app/*route returns 401 when anonymous except the allow-list (§3). This is the invariant that must be green before the edge@bearerdeploys. - Existing-test migration (I3 — the largest chunk): ~11 integration files browse
/api/appanonymously (test_app_episodes.py,test_app_search.py,test_app_discover.py,test_app_catalog.py,test_app_routes_consumer.py,test_app_artwork.py,test_app_episode_stats.py,test_app_podcast_signals.py,test_filler_topics_never_reach_http.py,test_app_multiuser_concurrency.py,test_viewer_corpus_library.py) plus Playwright stack-tests that browse without login — all migrate to an authed-client fixture. - Frontend unit/e2e: logged-out → landing (no action controls); gated route → login; post-login
?redirectreturns to the intended path (I2); cold-start awaits session restore so a logged-in user never flashes the landing (M2); 401 interceptor routes stale-shell PWAs to the landing (M3). - Edge
curlmatrix: anon web/→ coming-soon;Beareron/→ still coming-soon (B2);Beareron/api/app/*→ app; teaser paths → 200 anonymous;/comms/unsubscribe→ 200 anonymous. - Mobile: device build with no baked cookie → sign in → content loads (validates
@bearer); verify CORS covers anonymous teaser fetches fromcapacitor://localhost(M4).
Rollout & Monitoring¶
Corrected order (B1) — @bearer must NEVER reach prod while any content endpoint lacks an auth
dependency:
- RFC sign-off.
- One branch: anonymous-surface inventory + backend enforcement (auth on ~30 endpoints) +
server-side teaser clamp + frontend login-first +
LandingView+ existing-test migration. (Backend-first alone would 401 the current anonymous HomeView for invited beta users; frontend-first alone is unenforced — they ship together.) - Release gate: the enforcement-completeness curl matrix is green (every
/api/app/*→ 401 anonymous except the allow-list). - Edge (gated prod deploy — explicit approval): teaser exemption + path-scoped
@bearer; drop the baked cookie from the mobile build. Only after step 3 is green. - Device validation on the operator's phone → TestFlight (fastlane
beta). - (Public launch, later)
APP_SIGNUP_MODE=open+ drop the coming-soon gate. Confirm no deployment sharesPODCAST_SERVE_OPERATOR_PUBLIC=1(boot guard_guard_operator_public_open_signup,app.py) before the flip (I5).
Rollback: revert the player.caddy rule + redeploy; the frontend/backend changes are a normal app
revert. Abuse (open signup → unlimited scrape accounts) is a Non-Goal here — no rate-limiting in
this RFC (I5).
Resolved Decisions¶
All resolved by the operator this session:
- D1 — Enforcement depth: Backend-enforce now (frontend + backend), not frontend-only. Content endpoints require auth except the teaser set; survives the public-launch gate drop.
- D2 — Teaser source: reuse
/discover(recency) +/corpus/trending-topicsfor v1; no new curated endpoint yet. - D3 — Landing shape: dedicated
LandingViewfor logged-out (not an adaptedHomeView). - D4 — Mobile teaser reachability: gate-exempt the teaser endpoints at the edge, so the lure renders on web + mobile with no baked secret.
- D5 — Account signup: keep
APP_SIGNUP_MODE=allowlistduring beta (add tester emails), flip toopenat public launch.
References¶
infra/caddy/player.caddy— coming-soon gate (#1262)web/learning-player/src/router/index.ts— route guardweb/learning-player/src/stores/auth.ts,src/services/api.ts,src/services/tier.ts,src/services/native.tsweb/learning-player/src/views/HomeView.vue— current adaptive homeserver/app_discover.py,server/app_auth.py— discover feed + session verification