Skip to content

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_soon feature 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:

  1. ftl-frontend/src/App.tsx:406-425 — the RequireAuth wrapper reads useFlagsStore and redirects non-admin authenticated users to /coming-soon. This is the ONLY enforcement point.
  2. ftl-frontend/src/stores/flags.ts:18-26 — on /api/flags fetch failure, the catch block calls set({ loaded: true }) with no flags payload. coming_soon ?? false then evaluates to false, and users sail straight through. Explicit fail-open by design.
  3. ftl-backend/internal/handler/routes.go:196 — the protected route group applies only middleware.JWTAuth and middleware.Blocklist. There is zero server-side check for coming_soon or maintenance_mode on 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.go exposing ComingSoonGate(store flags.Store, allowAdmin bool) fiber.Handler. On every request:
  • If store.Get("coming_soon") is falsec.Next().
  • Else if allowAdmin is true AND the request's JWT claims include role: "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 flag launch_date (already in the table; read at middleware init or per-request via the cached store).
  • Wire it into routes.go at line ~196, between middleware.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 through AdminOnly() already). All gated routes are inside the protected group; routes outside it stay reachable so the /coming-soon page 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-soon when flags.coming_soon is true. This is UX — users shouldn't see API errors when the route would be blocked anyway. The fail-open behavior on /api/flags failure (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 /stadium if flags.coming_soon === true. This closes the redirect loop described in the bug inventory (#2). Implementation: read useFlagsStore.getState().flags.coming_soon inside the effect; if true, skip the navigate() call. Equivalent: gate useTutorialStore.getState().start() in Register.tsx:465-466 on !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/instruments while coming_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_soon removes 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 Unavailable instead of the /coming-soon page. Mitigation: in the global API error handler, treat a 503 with error: "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_date flag must be populated before flipping coming_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.