adr: 0007 title: Coming-soon enforced as backend hard gate (not frontend-only) status: accepted implementation-status: implemented date: 2026-05-22 deciders: [@amalkrsihna] affects-specs: [signup-and-onboarding] affects-code: - ftl-backend/internal/handler/routes.go # protected group wiring (~L196) - ftl-backend/internal/middleware/coming_soon.go # NEW middleware file - ftl-frontend/src/stores/flags.ts # fail-open behavior preserved - ftl-frontend/src/App.tsx # RequireAuth (~L406-425) - ftl-frontend/src/tutorial/TutorialOverlay.tsx # gate tutorial start on !coming_soon supersedes: null superseded-by: null
ADR-0007: Coming-soon enforced as backend hard gate¶
Pending implementation. Defines the enforcement model for the
coming_soonfeature flag during the pre-launch window. Today the flag is honored only in the React app; this ADR makes the backend the authority.
Context¶
A pre-launch period is planned before the tournament starts. During that window, the
/coming-soon landing page is the only surface most users should see. The coming_soon
boolean in the feature-flags table (read via GET /api/flags) controls this.
Today the gate is frontend-only. Three concrete code paths show the gap:
ftl-frontend/src/App.tsx:406-425— theRequireAuthwrapper readsuseFlagsStoreand redirects non-admin authenticated users to/coming-soon. This is the ONLY enforcement point.ftl-frontend/src/stores/flags.ts:18-26— on/api/flagsfetch failure, the catch block callsset({ loaded: true })with noflagspayload.coming_soon ?? falsethen evaluates tofalse, and users sail straight through. Explicit fail-open by design.ftl-backend/internal/handler/routes.go:196— theprotectedroute group applies onlymiddleware.JWTAuthandmiddleware.Blocklist. There is zero server-side check forcoming_soonormaintenance_modeon any endpoint, including/api/instruments,/api/instruments/:id,/api/instruments/:id/price-history,/api/trades, etc.
A user with a valid JWT cookie can curl any data endpoint while coming_soon=true and
receive full responses. For a pure marketing teaser this is annoying but tolerable. For any
pre-launch gating that protects real data — match telemetry, instrument lists, leaderboard
state, trade endpoints — it is a security hole.
The fail-open behavior in flags.ts is acceptable as a UX choice (a flags-endpoint hiccup
shouldn't lock everyone out of the app), but only if the backend is the real gate. Right
now there is no real gate.
Decision¶
We will enforce the coming_soon flag in the backend as a Fiber middleware on the
protected route group, returning 503 to non-admin requests. The frontend gate stays as a
UX layer (redirect to /coming-soon), but is no longer the authoritative enforcement.
Concrete changes the paired code PR will encode:
- New file
ftl-backend/internal/middleware/coming_soon.goexposingComingSoonGate(store flags.Store, allowAdmin bool) fiber.Handler. On every request: - If
store.Get("coming_soon")isfalse→c.Next(). - Else if
allowAdminis true AND the request's JWT claims includerole: "admin"→c.Next(). - Else → return HTTP 503 with body
{"error":"coming_soon","message":"FTL launches on <date>"}. The launch date string comes from a second flaglaunch_date(already in the table; read at middleware init or per-request via the cached store). - Wire it into
routes.goat line ~196, betweenmiddleware.JWTAuth(...)and the first route registration:protected.Use(middleware.ComingSoonGate(h.flagsStore, true)). This places it AFTER auth — we want to know whether the requester is admin before deciding whether to gate. - Do NOT apply it to
/api/flags,/api/auth/*,/api/public/*,/api/coming-soon/*(the marketing-page endpoints), or/api/admin/*(those go throughAdminOnly()already). All gated routes are inside theprotectedgroup; routes outside it stay reachable so the/coming-soonpage itself, login, and the new public referral validator (ADR-0008) continue to work. - Frontend
RequireAuth(App.tsx:406-425) keeps redirecting non-admins to/coming-soonwhenflags.coming_soonis true. This is UX — users shouldn't see API errors when the route would be blocked anyway. The fail-open behavior on/api/flagsfailure (flags.ts:18-26) is preserved deliberately. - TutorialOverlay (
ftl-frontend/src/tutorial/TutorialOverlay.tsx:150-153) — guard the auto-route effect: do not navigate the user toward/stadiumifflags.coming_soon === true. This closes the redirect loop described in the bug inventory (#2). Implementation: readuseFlagsStore.getState().flags.coming_sooninside the effect; if true, skip thenavigate()call. Equivalent: gateuseTutorialStore.getState().start()inRegister.tsx:465-466on!flags.coming_soon— pick whichever the operator finds cleaner; both achieve the same outcome.
Consequences¶
Positive¶
- Backend is the truth. A user with a stale JWT cookie cannot reach
/api/instrumentswhilecoming_soon=true. Security posture matches operator intent. - Operator can flip the gate from the admin panel without redeploy — the backend reads the flag from the same store the frontend does.
- Tutorial redirect loop is closed as a free consequence — guarding tutorial start on
!coming_soonremoves the trigger that drives the loop in bug #2 from the inventory. - Admin role bypass lets the operator validate prod before flipping the gate publicly.
Negative¶
- One more middleware layer on every protected request. Cost is negligible — a single
in-memory read from the flags store + a JWT claim lookup that's already cached on the
Fiber context after
JWTAuth. Adds <10µs per request. - Frontend UX gap risk. If the frontend gate misfires (e.g. flags-store load race) but
the backend correctly returns 503, the user sees
Error: 503 Service Unavailableinstead of the/coming-soonpage. Mitigation: in the global API error handler, treat a 503 witherror: "coming_soon"as a "redirect to /coming-soon" signal — converts backend enforcement into the same UX as the frontend gate.
Neutral / new obligations¶
- Frontend fail-open behavior preserved. This is a deliberate choice — flag-store hiccups should not break the app. The backend is now the real authority, so fail-open in the UI is harmless: users can't actually call gated endpoints even if the UI lets them try.
launch_dateflag must be populated before flippingcoming_soon=true, otherwise the 503 body's message will read "FTL launches on" literally. Add a startup check or admin-panel warning.
Alternatives considered¶
Alternative A: Fail-CLOSED on /api/flags failure¶
When /api/flags fetch fails on the frontend, treat coming_soon as true (block) rather
than false (pass).
Rejected. A flags-store hiccup would lock every authenticated user out of the app during the failure window. The cost of a brief flag-fetch hiccup blocking 100% of users far outweighs the cost of a fail-open during the failure window — especially now that the backend is the real gate. The backend's authoritative enforcement makes fail-open on the frontend safe.
Alternative B: Backend reads flag from env var instead of flags table¶
Set FTL_COMING_SOON=true as a Container Apps env var; backend middleware checks the env.
Rejected. The operator wants to flip the gate from the admin panel without redeploy — a env-var-driven flag requires a revision update on Container Apps, which means an extra 60-90s of cold-start churn. The flags table is already the source of truth and is read with sub-millisecond latency via the in-memory cache.
Alternative C: Apply the gate to ALL routes (including public)¶
Drop the gate at the top of the Fiber app, before route registration.
Rejected. Would block /api/flags, login, and /coming-soon itself — the very routes
the gated experience depends on. The protected group is the correct scope.
Alternative D: Frontend-only enforcement, accept the gap¶
Keep today's behavior. Document that /coming-soon is "marketing only" and any sensitive
data goes through a separate auth boundary.
Rejected. This would force a second auth boundary for any pre-launch data protection, duplicating work. Backend hard gate is one middleware that solves it once.
Note on the tutorial loop¶
Bug #2 in the pre-registration review (redirect loop between /coming-soon and /stadium
on first registration during coming-soon mode) is solved as a consequence of this ADR's
"don't auto-navigate on coming_soon=true" rule, not as a separate decision. Calling that
out here so a future reader doesn't go looking for a separate ADR-for-the-tutorial-loop.